[object Object]

← back to Restaurant Directory

rel=alternate JSON + HTTP Link header on / and /c/:slug

963d23ae8fb71a55e61a399cf20663fd3307167f · 2026-05-08 00:15:19 -0700 · Steve

Applies the cross-fleet directory pattern (VCL fbd4be4, NPH 51036bf) to
lacountyeats. /api/restaurants already exists as the JSON mirror; this commit
just surfaces it via the standard rel=alternate signals.

Changes:
- views/partials/head.ejs: conditional <link rel="alternate" type="application/json">
  when alternateJsonUrl is in render context
- src/api/server.ts: HTTP Link header (RFC 8288) on / and /c/:slug routes;
  alternateJsonUrl computed per-request reflecting current filter state
  (q + city + cuisine + limit + offset on /, or city= filter on /c/:slug)

Smoke (PORT=9746):
- GET / → 200, Link header present with canonical + alternate (?limit=50&offset=0)
- HTML head includes <link rel="alternate" type="application/json">

Cross-fleet rel=alternate tally now: VCL + NPH + lacountyeats = 3 of 5
directory targets. Remaining: lawyer-directory (autocomplete-SPA, doesn't fit
this pattern), professional-directory, animals.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 963d23ae8fb71a55e61a399cf20663fd3307167f
Author: Steve <steve@designerwallcoverings.com>
Date:   Fri May 8 00:15:19 2026 -0700

    rel=alternate JSON + HTTP Link header on / and /c/:slug
    
    Applies the cross-fleet directory pattern (VCL fbd4be4, NPH 51036bf) to
    lacountyeats. /api/restaurants already exists as the JSON mirror; this commit
    just surfaces it via the standard rel=alternate signals.
    
    Changes:
    - views/partials/head.ejs: conditional <link rel="alternate" type="application/json">
      when alternateJsonUrl is in render context
    - src/api/server.ts: HTTP Link header (RFC 8288) on / and /c/:slug routes;
      alternateJsonUrl computed per-request reflecting current filter state
      (q + city + cuisine + limit + offset on /, or city= filter on /c/:slug)
    
    Smoke (PORT=9746):
    - GET / → 200, Link header present with canonical + alternate (?limit=50&offset=0)
    - HTML head includes <link rel="alternate" type="application/json">
    
    Cross-fleet rel=alternate tally now: VCL + NPH + lacountyeats = 3 of 5
    directory targets. Remaining: lawyer-directory (autocomplete-SPA, doesn't fit
    this pattern), professional-directory, animals.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 src/api/server.ts               | 21 +++++++++++++++++++--
 src/web/views/partials/head.ejs |  3 +++
 2 files changed, 22 insertions(+), 2 deletions(-)

diff --git a/src/api/server.ts b/src/api/server.ts
index 1d31fd7..4fb61b9 100644
--- a/src/api/server.ts
+++ b/src/api/server.ts
@@ -156,7 +156,21 @@ app.get('/', async (req, res, next) => {
        LIMIT 6`
     )).rows : [];
 
-    res.render('list', { rows, total, page, limit, city, cuisine, q, cities, cuisines, heroCuisines, showHero, newestRows });
+    // rel=alternate JSON mirror — same query, paginated. Apps + agents can hit
+    // /api/restaurants with the same query string for JSON. RFC 8288 HTTP Link
+    // header in addition to the HTML <link rel> tag in head.ejs.
+    const baseUrl = (process.env.PUBLIC_URL || `http://localhost:${PORT}`).replace(/\/+$/, '');
+    const apiQs = new URLSearchParams();
+    if (q)       apiQs.set('q', q);
+    if (city)    apiQs.set('city', city);
+    if (cuisine) apiQs.set('cuisine', cuisine);
+    apiQs.set('limit', String(limit));
+    apiQs.set('offset', String(offset));
+    const alternateJsonUrl = `${baseUrl}/api/restaurants?${apiQs.toString()}`;
+    const canonicalUrl = baseUrl + '/' + (req.url.includes('?') ? req.url.split('?')[1] && '?' + req.url.split('?')[1] : '');
+    res.set('Link', `<${canonicalUrl}>; rel="canonical", <${alternateJsonUrl}>; rel="alternate"; type="application/json"`);
+
+    res.render('list', { rows, total, page, limit, city, cuisine, q, cities, cuisines, heroCuisines, showHero, newestRows, alternateJsonUrl });
   } catch (e) { next(e); }
 });
 
@@ -195,7 +209,10 @@ app.get('/c/:slug', async (req, res, next) => {
       [city]
     );
 
-    res.render('city', { city, slug, total, cityCuisines, topRows });
+    const baseUrl = (process.env.PUBLIC_URL || `http://localhost:${PORT}`).replace(/\/+$/, '');
+    const alternateJsonUrl = `${baseUrl}/api/restaurants?city=${encodeURIComponent(city)}&limit=50`;
+    res.set('Link', `<${baseUrl}/c/${slug}>; rel="canonical", <${alternateJsonUrl}>; rel="alternate"; type="application/json"`);
+    res.render('city', { city, slug, total, cityCuisines, topRows, alternateJsonUrl });
   } catch (e) { next(e); }
 });
 
diff --git a/src/web/views/partials/head.ejs b/src/web/views/partials/head.ejs
index d78de85..3981384 100644
--- a/src/web/views/partials/head.ejs
+++ b/src/web/views/partials/head.ejs
@@ -8,6 +8,9 @@
   </script>
   <title><%= typeof title !== 'undefined' ? title + ' — LA County Eats' : 'LA County Eats — every restaurant in LA County' %></title>
   <meta name="description" content="<%= typeof description !== 'undefined' ? description : 'Searchable directory of restaurants across Los Angeles County. ~37,000 spots. Address, neighborhood, cuisine, map.' %>">
+  <% if (typeof alternateJsonUrl !== 'undefined' && alternateJsonUrl) { %>
+    <link rel="alternate" type="application/json" href="<%= alternateJsonUrl %>" title="JSON results">
+  <% } %>
   <meta name="theme-color" content="#fdf9ee" media="(prefers-color-scheme: light)">
   <meta name="theme-color" content="#1a1814" media="(prefers-color-scheme: dark)">
   <link rel="preconnect" href="https://fonts.googleapis.com">

← bdac86f Remove all health-code/inspection/violation rendering and dr  ·  back to Restaurant Directory  ·  snapshot: initial structure (PLAN, REVIEW, db, migrations, s e322707 →