A page nothing links to does not break anything. The build passes, the page renders, the sitemap lists it, and a reviewer looking at the diff sees a perfectly good page. It is just quietly worthless, and you find out six months later when you wonder why it never ranked.
That failure, and two of its relatives, are what the test file in this post exists for:
it('leaves no page orphaned', () => {
// The header and footer are on every page, so what they link to counts.
const inbound = new Set<string>(globalNavPaths())
for (const node of CONTENT) {
for (const path of outboundLinksFor(node.path)) inbound.add(path)
}
for (const node of CONTENT) {
expect(inbound.has(node.path), `nothing links to ${node.path}`).toBe(true)
}
})
Twenty-one tests run against one array. Here is the array, and then the five tests I would actually copy into another project.
One list, and nothing below it is a second list
We sell quiz software to pubs. The marketing site is 72 pages across six topic clusters, and every one of them is a node in one file:
{
path: '/compare/paper-vs-app-quiz',
title: 'Paper vs App Quiz Nights',
shortLabel: 'Paper vs app',
heading: 'Pen and paper against phones',
description:
'Pen and paper is the real competitor. An honest comparison: what paper is genuinely better at, what the marking costs you every week, and when to stay on it.',
cluster: 'compare',
updated: '2026-09-14',
related: ['/compare/best-pub-quiz-apps', '/guides/quiz-night-equipment', '/tools/scoresheet-generator', '/features/live-leaderboard'],
},
A page needs a title, a description, a canonical, a sitemap entry, a breadcrumb trail, a place in the navigation and links to its neighbours. Written by hand that is eight lists to keep in step, and we had already learned that lesson at a smaller scale: three route lists, maintained independently, which disagreed badly enough that the sitemap advertised a page the auth gate redirected to /login.
So the file has a hard line in it:
// ─────────────────────────────────────────────────────────────────────────────
// Derivations. Everything below is computed; nothing below is a second list.
// ─────────────────────────────────────────────────────────────────────────────
The sitemap, the footer columns, the header dropdown, each hub's index of its own pages, the breadcrumbs and the related-links block are all below that line. Adding a page is one node and one page.tsx.
The graph is a function, not a crawl
The link tests need to know what links a page renders. The honest way to find that out is to render the page and look, which means a browser, a running server and a slow test. The cheap way is to make the components ask a function, and then test the function:
export function outboundLinksFor(path: string): string[] {
const node = nodeFor(path)
if (!node) return []
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]
}
Breadcrumb ancestors, a hub's list of its spokes, and the page's own related links. The components take their links from here rather than hard-coding them, which is what makes the function a description of reality rather than a hopeful model of it.
The limitation is worth stating, because the file states it:
A page that renders links the registry does not know about will still pass
(nothing can stop that), but a page that renders FEWER than this is the
failure worth catching.
That is the right asymmetry. An extra hand-written link in some prose is harmless. A missing structural link is the orphan.
Why the footer counts as inbound
The orphan test seeds its inbound set with globalNavPaths() before walking the graph, and that is not a convenience:
export function globalNavPaths(): string[] {
return ['/', '/pricing', '/faq', '/about', ...publishedClusters().map((c) => c.path)]
}
A cluster hub typically has no editorial link pointing at it. Nothing in the prose of a guide says "see also: Guides". The hubs are reachable because they are in the footer on every page, and the footer is the only site-wide navigation a mobile crawler ever sees on this site, since the header hides its links below the md breakpoint.
So the test has to count it, or it fails on six pages that are fine. The thing to notice is that this makes the orphan test depend on a real design decision rather than on a convention: if the hubs came out of the footer, these tests would start failing, which is exactly what you want from a test about reachability.
Its sibling is the cheapest useful test in the file:
it('gives every page somewhere to go', () => {
for (const node of CONTENT) {
expect(outboundLinksFor(node.path).length, `${node.path} links nowhere`).toBeGreaterThan(0)
}
})
Orphans are pages nothing reaches. Dead ends are pages that reach nothing. Both are easy to produce by writing a page and forgetting to fill in related, and only one of them is talked about.
The same invariant from both ends
it('links every spoke back up to its hub', () => { /* ... */ })
it('links every hub down to all of its spokes', () => { /* ... */ })
Two tests that look like one test written twice. They are not, because the two directions come from different machinery: upward comes from the breadcrumb trail, downward comes from spokesOf. A bug in either one leaves the other passing, and the symptom of a hub that has stopped listing one of its spokes is a page that is still reachable, still in the sitemap, and receiving nothing from the page that is supposed to be its parent.
There is a structural version of the same idea:
it('keeps every spoke underneath its own hub path', () => {
expect(
spoke.path.startsWith(cluster.path + '/'),
`${spoke.path} is in the ${cluster.key} cluster but not under ${cluster.path}`
).toBe(true)
})
Which says the URL shape and the declared cluster have to agree. A page at /guides/virtual-quiz-night declaring itself part of the features cluster would otherwise produce a breadcrumb trail that contradicts its own URL, and nothing else in the system would mind.
A title's length is measured after the template
This is the test I would least have thought to write:
it('keeps rendered titles inside the length a search result will show', () => {
// What a crawler sees is what the root layout's template produces, not
// the short title on the node: `%s | PubTrivia` costs a further 12
// characters, so the budget has to be measured after it is applied.
for (const node of CONTENT) {
const rendered = node.path === '/' ? node.title : `${node.title} | ${SITE_NAME}`
expect(rendered.length, `${node.path} renders as a ${rendered.length}-char title`)
.toBeLessThanOrEqual(TITLE_LIMIT)
}
})
Checking node.title.length <= 60 is the obvious test and it is wrong, because the title on the node is not the title anybody sees. Next's title.template appends the site name, and the 12 characters it costs are the difference between a title that fits in a search result and one that truncates mid-word.
The homepage is special-cased because it sets an absolute title rather than going through the template, which is the kind of exception that quietly makes a test either wrong or useless if you do not handle it.
The description limit is 160 for the same reason, with one extra argument in its comment: the description is also the blurb under this page's name in the footer and on its hub, so a long one is a ragged wrapped line in three places rather than one.
Never repeat a title
it('never repeats a title, so two pages cannot compete for the same result', () => {
const titles = CONTENT.map((node) => node.title)
expect(new Set(titles).size).toBe(titles.length)
})
Two pages with the same title are two pages asking to be the same search result, and the engine picks one. With six clusters covering adjacent subject matter it is genuinely easy to write "Pub Quiz Questions" twice without noticing, because the two pages are in different directories and nobody reads the whole list.
Declare the cluster before the hub exists, derive the visibility
export function publishedClusters(): Cluster[] {
return CLUSTERS.filter((cluster) => BY_PATH.has(cluster.path))
}
All six clusters are declared from the day the plan commits to them, which is usually a phase or two before anything is written. That matters because a spoke needs to name the cluster it belongs to, and the cluster has to exist as a value for that to typecheck.
What decides whether a cluster appears anywhere a user can see is whether its hub page exists. So a half-built cluster cannot leak into the footer as a dead link, and finishing the hub publishes it to the header, the footer and the sitemap in one commit. There is a test for the inverse too, that nothing is published without a hub, which is the sort of assertion that reads as paranoia until the day somebody deletes a page.
The literal is checked, the widened view is read
const CONTENT_LITERAL = [ /* ... */ ] as const satisfies readonly ContentNode[]
export const CONTENT: readonly ContentNode[] = CONTENT_LITERAL
as const satisfies is what makes a missing description or a misspelled cluster key a compile error rather than a runtime surprise. It also narrows each node to its own exact shape, and that is the part that bites: a node with no cluster field then has no cluster property at all, so reading node.cluster on the literal is a type error rather than undefined.
Hence two names for one array. The literal exists only to be checked, and everything, here and in the tests, reads the widened view.
The test that would have caught the original bug
it('has a public route prefix for every declared cluster', () => {
// A cluster whose prefix is missing from PUBLIC_ROUTES ships a hub that
// redirects every crawler to /login.
for (const prefix of CLUSTER_ROUTE_PREFIXES) {
expect(isPublicRoute(prefix), `${prefix} is not a public route`).toBe(true)
expect(isPublicRoute(`${prefix}/a-spoke-page`)).toBe(true)
}
})
Asserting on ${prefix}/a-spoke-page, a path that does not exist and never will, is the part I like. The auth allowlist matches by prefix, so testing only the exact hub path would pass while every page beneath it was gated. The fake path tests the matching rule rather than the entry.
All 21 pass in about 15 milliseconds, which is the other argument for making the graph a function: a link-integrity suite with no browser and no server in it is one you run on every save.
Have a look
The whole graph is public, so you can check the claims from outside:
# 72 pages, which is CONTENT plus the three legal pages
curl -s https://pub-trivia.app/sitemap.xml | grep -c "<loc>"
Then pick any spoke, for instance pub-trivia.app/compare/paper-vs-app-quiz, which is the node quoted at the top of this post. The breadcrumb is breadcrumbFor, the four links at the foot under "Keep reading" are its related array rendered through the registry, and the titles and descriptions in that block are the same strings the linked pages use for their own <title> and meta description.
One thing you will notice if you follow them: the sideways links are not reciprocal. /compare/best-pub-quiz-apps does link back, and the other three do not. Nothing asserts that they should, which is deliberate. "This comparison is worth reading next" is an editorial judgement made from one page about another, and it is not automatically true in reverse: a page about quiz night equipment has better things to send a reader to than a page about paper versus apps. The only links the tests force in both directions are the structural ones, between a spoke and its hub, because those describe where a page sits rather than what is worth reading after it.
The free tier needs no card if you want to see what all 72 pages are pointing at.
Top comments (0)