Skip to content

Latest commit

 

History

History
164 lines (133 loc) · 7.54 KB

File metadata and controls

164 lines (133 loc) · 7.54 KB

headless-seo-starter

Headless SEO done right — runnable. A clone-and-go Next.js (App Router) starter that gets technical SEO correct end-to-end against a headless-CMS-shaped data layer. Fork it to start a project, or read it to learn the patterns.

In a headless architecture the SEO-relevant HTTP response is assembled across systems that don't share a deployment: the CMS owns the data, the frontend owns the response, and the CDN can modify it on the way out. Regressions live in the seams — a title set client-side, a soft 404, a production canonical leaking onto a preview deploy. This starter pins down the frontend's responsibilities so those seams don't leak, and proves it with tests.

It is built on Next.js 16 (App Router), React 19, and TypeScript (strict). It runs entirely on a mock CMS — no API keys, no network.


Quick start

npm install
npm run dev          # http://localhost:3000 — runs on the mock CMS, no keys

Other scripts:

Command What it does
npm run dev Dev server on the mock CMS.
npm run build / npm start Production build / serve.
npm run typecheck tsc --noEmit (strict).
npm test Unit tests (Vitest).
npm run check:contract Validate the SEO data contract over the fixtures.
npm run build && npm run test:e2e Playwright e2e against a real server.

The e2e suite boots two servers from one build — port 3000 as production, port 3100 as preview — to assert the X-Robots-Tag env isolation. See playwright.config.ts.


What it gets right

The frontend (rendering layer), not the CMS, is the source of truth for what is indexable. Each responsibility below links to the file that implements it.

  1. Server-rendered metadata. Title, description, canonical, Open Graph, and robots live in the initial HTML via generateMetadata — never set in a useEffect after hydration (which crawlers never see). → lib/seo/metadata.ts, app/blog/[slug]/page.tsx
  2. Real 404s, not soft 404s. A missing/unpublished slug returns a genuine HTTP 404 via notFound() — not a 200 with a "not found" body. → app/blog/[slug]/page.tsx, app/products/[handle]/page.tsx
  3. Centralized canonical computation. One deterministic function builds every canonical: a CMS override wins only if it's a valid http/https URL; otherwise it derives from the env origin + optional locale + path, keeping only an allowlisted set of indexable params (sorted, encoded). → lib/seo/canonical.ts
  4. JSON-LD from the same data as the body. Product structured data is computed from the exact object the visible markup renders (no second fetch), with brand/aggregateRating included only when present and valid. → lib/seo/product-jsonld.ts, components/seo/ProductSchema.tsx
  5. Sitemap + robots from the route source of truth, with <lastmod> from a content-level timestamp (contentUpdatedAt) — not a deploy timestamp. → app/sitemap.ts, app/robots.ts
  6. Environment isolation. Non-production deploys emit X-Robots-Tag: noindex, nofollow so preview/staging is never indexed, and the origin is environment-scoped so previews never emit production canonicals. → proxy.ts, lib/seo/env.ts, next.config.ts
  7. A CMS data contract. The SEO fields the frontend requires are validated so schema drift is caught at the boundary, not downstream. → lib/contract/validate.ts

Bonus: faceted-navigation indexability tiers

A category route demonstrates a tier policy for faceted nav — base and curated single-facet pages are indexable/self-canonical; sort/view variants canonical to base; uncurated or multi-facet combos are noindex; tracking/session params are excluded entirely. → lib/seo/facets.ts, app/category/[slug]/page.tsx

Correctness pitfalls this starter refuses to reintroduce

  • params is a Promise (async in Next.js 15, mandatory in 16) and is always awaited — never the old synchronous { params: { slug } } shape.
  • The title is set in generateMetadata on the server — never via document.title in a useEffect.
  • isValidUrl restricts canonical overrides to http:/https:new URL("javascript:…") and ftp: parse fine, so a bare new URL() check would accept a poisoned canonical.
  • aggregateRating is omitted unless count > 0 && average > 0 — emitting reviewCount: 0 is a Rich Results violation.
  • A noindex page is never given a canonical to a different URL — the signals conflict, so the canonical is dropped entirely.

A note on Next.js 16: the spec was written for Next.js 15, where params first became async. This starter builds on Next.js 16, which makes that async shape mandatory (so the patterns here are now enforced by the framework) and renames middleware.ts to proxy.ts — used here for the X-Robots-Tag header.


How to swap in a real CMS

There is exactly one seam: lib/cms/index.ts. Today its getPost / getProduct / getCategory / getAllPublished functions read local fixtures. To wire in Contentful, Sanity, Hygraph, Strapi, or anything else:

  1. Replace the function bodies with your CMS query. Keep the signatures async — the rest of the app already awaits them.
  2. Map the CMS response onto the types in lib/cms/types.ts.
  3. Run the mapped record through assertValidRecord so a renamed or retyped field fails loudly at the boundary instead of silently degrading your SEO.

lib/cms/adapters/contentful.ts is a shape-mapping stub showing exactly that pattern. Everything above the seam (metadata, canonicals, sitemap, JSON-LD) stays unchanged.

Set your origin per environment with SITE_ORIGIN (see .env.example).


⚠️ This is a starting point

Your CMS schema, your URL structure, and your indexable-param set are yours to wire in — the defaults here are illustrative. And whatever you build: don't cloak. Serve crawlers the same content as users.


Project layout

app/
  layout.tsx                 # root metadata, env-scoped metadataBase
  page.tsx                   # index of fixture content
  blog/[slug]/page.tsx       # async params, notFound(), generateMetadata
  products/[handle]/page.tsx # ProductSchema from the page data
  category/[slug]/page.tsx   # faceted-nav indexability tiers
  sitemap.ts                 # from getAllPublished(), lastmod = contentUpdatedAt
  robots.ts
  not-found.tsx
proxy.ts                     # X-Robots-Tag noindex when env != production
lib/
  cms/                       # mock CMS: types, fixtures, the fetch SEAM, adapter stub
  seo/                       # env, canonical, metadata, product-jsonld, facets
  contract/validate.ts       # the SEO data contract
components/seo/ProductSchema.tsx
scripts/check-contract.ts    # npm run check:contract
tests/                       # vitest unit tests
e2e/                         # playwright specs

License & contributing

MIT — see LICENSE. Contributions welcome; see CONTRIBUTING.md.