← back to NationalPaperHangers
rel=alternate JSON + HTTP Link header on /find — applies VCL pattern
51036bf4279b72138ef6f0192d2f2e2ff75af88d · 2026-05-07 23:19:09 -0700 · Steve
New /api/find route mirrors /find filter logic (q, segment, material, zip,
state, show) and returns JSON wrapped in {query, pagination, results}.
HTML head adds <link rel="alternate" type="application/json"> when
alternateJsonUrl is in the render context. /find route now sets HTTP
Link header (RFC 8288) with rel=canonical + rel=alternate.
Smoke-tested locally on :9765 — 522 installers no-filter, 20 NY,
0 CA+residential (correctly applied; market_segments uses
'luxury_residential' not 'residential' — feature, not bug).
Same pattern shipped on Ventura Claw Leads earlier today (commit fbd4be4).
Now applied to NationalPaperHangers. Future targets: lawyer-directory,
lacountyeats, professional-directory, animals.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Files touched
M routes/public.jsM views/partials/head.ejs
Diff
commit 51036bf4279b72138ef6f0192d2f2e2ff75af88d
Author: Steve <steve@designerwallcoverings.com>
Date: Thu May 7 23:19:09 2026 -0700
rel=alternate JSON + HTTP Link header on /find — applies VCL pattern
New /api/find route mirrors /find filter logic (q, segment, material, zip,
state, show) and returns JSON wrapped in {query, pagination, results}.
HTML head adds <link rel="alternate" type="application/json"> when
alternateJsonUrl is in the render context. /find route now sets HTTP
Link header (RFC 8288) with rel=canonical + rel=alternate.
Smoke-tested locally on :9765 — 522 installers no-filter, 20 NY,
0 CA+residential (correctly applied; market_segments uses
'luxury_residential' not 'residential' — feature, not bug).
Same pattern shipped on Ventura Claw Leads earlier today (commit fbd4be4).
Now applied to NationalPaperHangers. Future targets: lawyer-directory,
lacountyeats, professional-directory, animals.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
routes/public.js | 66 ++++++++++++++++++++++++++++++++++++++++++++++++-
views/partials/head.ejs | 3 +++
2 files changed, 68 insertions(+), 1 deletion(-)
diff --git a/routes/public.js b/routes/public.js
index 6a14089..1a3e73a 100644
--- a/routes/public.js
+++ b/routes/public.js
@@ -126,11 +126,75 @@ router.get('/find', async (req, res, next) => {
if (material) _mapParams.set('material', material);
const mapHref = '/map' + (_mapParams.toString() ? '?' + _mapParams.toString() : '');
+ // rel=alternate JSON mirror — apps + agents + structured-data crawlers
+ // can fetch the same filtered list as JSON without HTML scraping.
+ const _baseUrl = (process.env.PUBLIC_URL || 'https://www.nationalpaperhangers.com').replace(/\/+$/, '');
+ const _apiQs = new URLSearchParams();
+ if (q) _apiQs.set('q', q);
+ if (segment) _apiQs.set('segment', segment);
+ if (material) _apiQs.set('material', material);
+ if (zip) _apiQs.set('zip', zip);
+ if (state) _apiQs.set('state', state);
+ if (show !== 'all') _apiQs.set('show', show);
+ const alternateJsonUrl = _baseUrl + '/api/find' + (_apiQs.toString() ? '?' + _apiQs.toString() : '');
+ const canonicalUrl = _baseUrl + '/find' + (_apiQs.toString() ? '?' + _apiQs.toString() : '');
+
+ // HTTP Link header — RFC 8288. Crawlers that don't parse HTML still see this.
+ res.set('Link', `<${canonicalUrl}>; rel="canonical", <${alternateJsonUrl}>; rel="alternate"; type="application/json"`);
+
res.render('public/find', {
title, metaDescription,
canonicalPath: '/find',
installers,
- q, segment, material, zip, state, show, toggleHref, mapHref
+ q, segment, material, zip, state, show, toggleHref, mapHref,
+ alternateJsonUrl
+ });
+ } catch (err) { next(err); }
+});
+
+// JSON-API mirror of /find — same filter logic, JSON output. Surfaced via
+// rel="alternate" link tag + HTTP Link header on the HTML /find route.
+router.get('/api/find', async (req, res, next) => {
+ try {
+ const q = (req.query.q || '').trim();
+ const segment = (req.query.segment || '').trim();
+ const material = (req.query.material || '').trim();
+ const zip = (req.query.zip || '').trim();
+ const state = (req.query.state || '').trim();
+ const show = (req.query.show || 'all').trim();
+
+ const where = (show === 'claimed')
+ ? [`status = 'active' AND (claim_status = 'self' OR claim_status = 'claimed')`]
+ : [`(status = 'active' OR claim_status = 'unclaimed')`];
+ const params = [];
+ let i = 1;
+
+ if (q) {
+ params.push(`%${q.toLowerCase()}%`);
+ where.push(`(LOWER(business_name) LIKE $${i} OR LOWER(headline) LIKE $${i} OR LOWER(bio) LIKE $${i} OR LOWER(city) LIKE $${i})`);
+ i++;
+ }
+ if (segment) { params.push(segment); where.push(`$${i} = ANY(market_segments)`); i++; }
+ if (material) { params.push(material); where.push(`$${i} = ANY(materials)`); i++; }
+ if (zip) { params.push(zip); where.push(`zip = $${i}`); i++; }
+ if (state) { params.push(state.toUpperCase()); where.push(`state = $${i}`); i++; }
+
+ const installers = await db.many(`
+ SELECT i.id, i.slug, i.business_name, i.headline, i.city, i.state, i.zip,
+ i.market_segments, i.materials, i.tier, i.claim_status, i.verified, i.website
+ FROM installers i
+ WHERE ${where.join(' AND ')}
+ ORDER BY (CASE WHEN claim_status = 'claimed' OR claim_status = 'self' THEN 0 ELSE 1 END),
+ (CASE tier WHEN 'enterprise' THEN 0 WHEN 'signature' THEN 1 WHEN 'pro' THEN 2 ELSE 3 END),
+ verified DESC, updated_at DESC
+ LIMIT 600
+ `, params);
+
+ res.set('Cache-Control', 'public, max-age=300');
+ res.json({
+ query: { q, segment: segment || null, material: material || null, zip: zip || null, state: state || null, show },
+ pagination: { totalCount: installers.length, limit: 600 },
+ results: installers
});
} catch (err) { next(err); }
});
diff --git a/views/partials/head.ejs b/views/partials/head.ejs
index 78ebf50..f459170 100644
--- a/views/partials/head.ejs
+++ b/views/partials/head.ejs
@@ -16,6 +16,9 @@
%>
<meta name="description" content="<%= _metaDesc %>">
<link rel="canonical" href="<%= _canonicalUrl %>">
+ <% if (typeof alternateJsonUrl !== 'undefined' && alternateJsonUrl) { %>
+ <link rel="alternate" type="application/json" href="<%= alternateJsonUrl %>" title="JSON results">
+ <% } %>
<meta property="og:type" content="website">
<meta property="og:url" content="<%= _canonicalUrl %>">
<meta property="og:site_name" content="National Paper Hangers">
← e6efc97 Fix legal-page ZIP in CAN-SPAM footer: 91335→91403
·
back to NationalPaperHangers
·
nph-stripe-plumbing: scaffold e2e-stripe-flow.js + synthesis 6f348e2 →