[object Object]

← 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

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 →