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.
npm install
npm run dev # http://localhost:3000 — runs on the mock CMS, no keysOther 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 aspreview— to assert theX-Robots-Tagenv isolation. See playwright.config.ts.
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.
- Server-rendered metadata. Title, description, canonical, Open Graph, and
robots live in the initial HTML via
generateMetadata— never set in auseEffectafter hydration (which crawlers never see). → lib/seo/metadata.ts, app/blog/[slug]/page.tsx - 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 - 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
- JSON-LD from the same data as the body.
Productstructured data is computed from the exact object the visible markup renders (no second fetch), withbrand/aggregateRatingincluded only when present and valid. → lib/seo/product-jsonld.ts, components/seo/ProductSchema.tsx - 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 - Environment isolation. Non-production deploys emit
X-Robots-Tag: noindex, nofollowso 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 - 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
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
paramsis aPromise(async in Next.js 15, mandatory in 16) and is alwaysawaited — never the old synchronous{ params: { slug } }shape.- The title is set in
generateMetadataon the server — never viadocument.titlein auseEffect. isValidUrlrestricts canonical overrides tohttp:/https:—new URL("javascript:…")andftp:parse fine, so a barenew URL()check would accept a poisoned canonical.aggregateRatingis omitted unlesscount > 0 && average > 0— emittingreviewCount: 0is a Rich Results violation.- A
noindexpage 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
paramsfirst 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 renamesmiddleware.tstoproxy.ts— used here for theX-Robots-Tagheader.
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:
- Replace the function bodies with your CMS query. Keep the signatures async — the rest of the app already awaits them.
- Map the CMS response onto the types in lib/cms/types.ts.
- Run the mapped record through
assertValidRecordso 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).
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.
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
MIT — see LICENSE. Contributions welcome; see CONTRIBUTING.md.