Adding a marketing page to a Next.js site is deceptively expensive. The page itself takes an hour. Then it needs a title, a meta description, a canonical URL, a sitemap entry, a breadcrumb trail, a JSON-LD graph, a place in the footer, links out to its neighbours, and at least one link in from somewhere else so it is not an orphan.
That is eight hand-kept lists. At eight pages you can hold them in your head. pub-trivia.app has about seventy, and at seventy the lists do not disagree if you are careful, they disagree because you are human.
We know this because ours already had. Three route lists, written independently: the middleware auth allowlist, the sitemap, and robots.txt. /about shipped in the sitemap while the auth gate redirected every crawler that followed it to /login. Three lists, eight pages, already broken.
So the whole thing is derived from one array now. Here is the shape, and the tests that keep it honest.
One node per page
export type ContentNode = {
path: string // root-relative, '/' for the homepage
title: string // the short <title>, without the site name
heading: string // the <h1>, allowed to be longer
shortLabel?: string // for breadcrumbs and footers, when the title is too wide
description: string // the meta description AND the link preview text
cluster?: ClusterKey // which topic hub this sits under
updated: string // YYYY-MM-DD, the day the words changed
related: readonly string[] // sideways links
}
Two fields there are the whole argument for doing it this way.
description is used twice. It is the meta description, and it is the line shown under this page's name anywhere else on the site that links to it. Written once, so a link list structurally cannot describe a page differently from how the page describes itself. That is the single most common inconsistency on a content site, and it just cannot happen here.
updated is a date you type. The sitemap previously stamped every URL with the build date, which told crawlers our terms of service had been revised this morning, every morning. A lastmod that is always "now" is worse than no lastmod: it is a signal you have deliberately made meaningless. So it is a field, bumped when the words change and not when the file is reformatted.
Everything else is a function
export function breadcrumbFor(path: string): Crumb[]
export function outboundLinksFor(path: string): string[]
export function globalNavPaths(): string[]
export const INDEXABLE_CONTENT_PATHS = CONTENT.map((node) => node.path)
The sitemap maps over the registry. The footer reads publishedClusters(). The breadcrumb component calls breadcrumbFor(), and so does the BreadcrumbList JSON-LD, so the visible trail and the structured one cannot diverge. The related-links block at the bottom of each page calls outboundLinksFor().
That last one is worth reading, because it encodes an editorial rule as code:
export function outboundLinksFor(path: string): string[] {
const links = new Set<string>()
for (const crumb of breadcrumbFor(path)) if (crumb.path !== path) links.add(crumb.path)
if (node.cluster && isHub(node)) for (const spoke of spokesOf(node.cluster)) links.add(spoke.path)
for (const related of node.related) links.add(related)
links.delete(path)
return [...links]
}
The link up to the hub is never written in related, because it is structural and comes from the breadcrumb. A hub automatically links to all of its spokes. related is only for the sideways links a human has to choose. The less a human has to type, the less there is to get wrong.
The tests are the interesting part
A derived structure is only as good as its invariants. Ours are ordinary unit tests over the array, and they run in milliseconds:
- No duplicate paths.
- No duplicate titles, so two pages cannot compete for the same query.
- Descriptions within the length a search result will show, so you find out at commit time rather than by watching a snippet get truncated in the wild.
- Rendered titles within length, where "rendered" means after the layout template appends the site name. The template is part of the length, so the test has to model it.
-
updatedparses as a real YYYY-MM-DD, because a typo goes straight into the sitemap. - Every internal link points at a page that exists, which is a dead-link check that never needs a crawler.
- No page links to itself.
- No orphans. Every page has at least one inbound link, counting the header and footer as inbound, which is why the hubs live in the footer.
- Every page has somewhere to go. No dead ends.
- Every spoke links up to its hub and every hub links down to all its spokes.
- Every spoke's path sits underneath its hub's path, so the URL structure agrees with the link structure.
- A cluster is not published before its hub page exists, so the footer cannot advertise a 404.
Then a second suite checks the registry against the route lists: every content page is in the sitemap, every content page is reachable without a session, every cluster has a matching public route prefix, and the sign-in pages are absent from the sitemap while remaining crawlable.
That last distinction is one people get wrong in both directions:
A crawler that follows a link to
/loginshould get the page rather than a redirect. But a sitemap is a statement about which pages you would like ranked, and a sign-in form is not one of them.
The rule that makes it work
The registry describes pages that EXIST. A node with no
page.tsxbehind it is a 404 in the sitemap and a dead link in the footer, so nodes land in the same commit as their page.
There is one honest weakness in the design, and that is it. The array is a claim about the filesystem, and the tests check the array's internal consistency, not that each path resolves to a real route. Keeping the node and the page in the same commit is a discipline, not a guarantee. If I were extending this, that is the test I would add next.
Go and pull on it
Everything above is observable from outside. pub-trivia.app/sitemap.xml is generated from the array, with per-page lastmod dates that differ from each other. Open any spoke, for example the question timer feature, and the breadcrumb, the related links at the bottom, the meta description and the BreadcrumbList JSON-LD in the source all come out of one node. Then open the hub and notice it lists every spoke without anybody maintaining that list.
If you are curious what all these pages are actually selling, the free tier is right there and does not ask for a card.
Top comments (0)