← back to Ventura Claw Leads
rel=alternate JSON + HTTP Link header on /find paginated routes
fbd4be4342165add5329d5469387700742a40192 · 2026-05-07 17:50:23 -0700 · Steve
New /api/find route mirrors /find query+filter+pagination, returns JSON.
HTML head adds <link rel="alternate" type="application/json"> when
alternateJsonUrl is in render context. /find route now sets HTTP Link
header with rel=canonical, rel=prev, rel=next, rel=alternate (RFC 8288).
Smoke-tested locally:
- GET /api/find?vertical=beauty&page=2 returns {query,pagination,results}
- HEAD /find?vertical=beauty&page=2 carries 4-entry Link: header
- HTML head emits 3 <link rel> tags including the new alternate
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Files touched
M routes/public.jsM views/partials/head.ejs
Diff
commit fbd4be4342165add5329d5469387700742a40192
Author: Steve <steve@designerwallcoverings.com>
Date: Thu May 7 17:50:23 2026 -0700
rel=alternate JSON + HTTP Link header on /find paginated routes
New /api/find route mirrors /find query+filter+pagination, returns JSON.
HTML head adds <link rel="alternate" type="application/json"> when
alternateJsonUrl is in render context. /find route now sets HTTP Link
header with rel=canonical, rel=prev, rel=next, rel=alternate (RFC 8288).
Smoke-tested locally:
- GET /api/find?vertical=beauty&page=2 returns {query,pagination,results}
- HEAD /find?vertical=beauty&page=2 carries 4-entry Link: header
- HTML head emits 3 <link rel> tags including the new alternate
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
routes/public.js | 93 ++++++++++++++++++++++++++++++++++++++++++++++++-
views/partials/head.ejs | 3 ++
2 files changed, 95 insertions(+), 1 deletion(-)
diff --git a/routes/public.js b/routes/public.js
index ebb02f2..50015fe 100644
--- a/routes/public.js
+++ b/routes/public.js
@@ -192,6 +192,30 @@ router.get('/find', async (req, res, next) => {
// indexes /find?vertical=beauty&page=2 as its own page, not as a dup of
// /find. Use the same buildPageUrl helper to ensure consistent ordering.
const canonicalPath = buildPageUrl(page).replace(baseUrl, '');
+ const canonicalUrl = baseUrl + canonicalPath;
+
+ // rel=alternate JSON representation of this exact query — apps + agents +
+ // structured-data crawlers can fetch the same paginated results without
+ // HTML scraping. Mirrors the HTML query string into /api/find.
+ const buildApiUrl = (n) => {
+ const qsParts = [];
+ if (q) qsParts.push(`q=${encodeURIComponent(q)}`);
+ if (v) qsParts.push(`vertical=${encodeURIComponent(v)}`);
+ if (city) qsParts.push(`city=${encodeURIComponent(city)}`);
+ if (sortKey && orderClauses[sortKey] && sortKey !== 'featured') qsParts.push(`sort=${encodeURIComponent(sortKey)}`);
+ if (n > 1) qsParts.push(`page=${n}`);
+ return baseUrl + '/api/find' + (qsParts.length ? '?' + qsParts.join('&') : '');
+ };
+ const alternateJsonUrl = buildApiUrl(page);
+
+ // HTTP Link header — equivalent to <link rel> tags in head, but readable
+ // by crawlers that don't parse HTML (and survives any proxy that strips
+ // head tags). Order matches RFC 8288.
+ const linkParts = [`<${canonicalUrl}>; rel="canonical"`];
+ if (prevUrl) linkParts.push(`<${prevUrl}>; rel="prev"`);
+ if (nextUrl) linkParts.push(`<${nextUrl}>; rel="next"`);
+ linkParts.push(`<${alternateJsonUrl}>; rel="alternate"; type="application/json"`);
+ res.set('Link', linkParts.join(', '));
res.render('public/find', {
title: customTitle,
@@ -200,7 +224,74 @@ router.get('/find', async (req, res, next) => {
sort: orderClauses[sortKey] ? sortKey : 'featured',
customH1, verticalLabel,
page, totalPages, pageSize: PAGE_SIZE,
- prevUrl, nextUrl, canonicalPath
+ prevUrl, nextUrl, canonicalPath,
+ alternateJsonUrl
+ });
+ } catch (err) { next(err); }
+});
+
+// JSON-API mirror of /find — same query/filter/pagination, JSON output.
+// Surfaced via rel="alternate" link tag + HTTP Link header on the HTML /find
+// route so apps/agents/structured-data crawlers can fetch the same data
+// without scraping HTML. Matches /find's whitelist + page caps exactly.
+router.get('/api/find', async (req, res, next) => {
+ try {
+ const q = (req.query.q || '').trim().slice(0, 120);
+ const v = (req.query.vertical || '').trim();
+ const city = (req.query.city || '').trim().slice(0, 60);
+
+ const where = [`status = 'active'`];
+ const params = [];
+ if (q) {
+ params.push(`%${q.toLowerCase()}%`);
+ where.push(`(LOWER(business_name) LIKE $${params.length} OR LOWER(headline) LIKE $${params.length} OR LOWER(neighborhood) LIKE $${params.length})`);
+ }
+ if (v && BY_KEY[v]) {
+ params.push(v);
+ where.push(`vertical = $${params.length}`);
+ }
+ if (city) {
+ params.push(city);
+ where.push(`city ILIKE $${params.length}`);
+ }
+
+ const sortKey = String(req.query.sort || 'featured').trim();
+ const orderClauses = {
+ featured: `tier = 'premier' DESC, tier = 'standard' DESC, claim_status IN ('self','claimed') DESC, business_name ASC`,
+ newest: `created_at DESC`,
+ updated: `updated_at DESC`,
+ name_asc: `business_name ASC`,
+ name_desc: `business_name DESC`,
+ vertical: `vertical ASC, business_name ASC`
+ };
+ const orderBy = orderClauses[sortKey] || orderClauses.featured;
+
+ const PAGE_SIZE = 200;
+ const MAX_PAGE = 50;
+ const requestedPage = parseInt(req.query.page || '1', 10);
+ const page = Math.max(1, Math.min(MAX_PAGE, isNaN(requestedPage) ? 1 : requestedPage));
+ const offset = (page - 1) * PAGE_SIZE;
+
+ const businesses = await db.many(`
+ SELECT id, slug, business_name, vertical, headline, neighborhood, city, state, tier, claim_status, verified, photo_path
+ FROM businesses
+ WHERE ${where.join(' AND ')}
+ ORDER BY ${orderBy}, id ASC
+ LIMIT ${PAGE_SIZE} OFFSET $${params.length + 1}
+ `, [...params, offset]);
+
+ const totalForFilter = await db.one(
+ `SELECT COUNT(*)::int AS n FROM businesses WHERE ${where.join(' AND ')}`,
+ params
+ );
+ const totalCount = totalForFilter?.n ?? businesses.length;
+ const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE));
+
+ res.set('Cache-Control', 'public, max-age=300');
+ res.json({
+ query: { q, vertical: v || null, city: city || null, sort: orderClauses[sortKey] ? sortKey : 'featured' },
+ pagination: { page, totalPages, pageSize: PAGE_SIZE, totalCount },
+ results: businesses
});
} catch (err) { next(err); }
});
diff --git a/views/partials/head.ejs b/views/partials/head.ejs
index 5b52894..8a0d95d 100644
--- a/views/partials/head.ejs
+++ b/views/partials/head.ejs
@@ -26,6 +26,9 @@
<% if (typeof nextUrl !== 'undefined' && nextUrl) { %>
<link rel="next" href="<%= nextUrl %>">
<% } %>
+ <% 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="Ventura Claw">
← a182931 Footer centered: 5-col equal grid + text-align center on eve
·
back to Ventura Claw Leads
·
Fix legal-page ZIP in CAN-SPAM footer: 91335→91403 a6b54c3 →