This app has 94 page.tsx files, 7 layouts, and not one dynamic segment. No [slug], no [...path], no generateStaticParams. Every one of the 72 URLs in the sitemap is a directory on disk with a file in it.
The six content clusters account for 65 of those files and 8,431 lines, an average of 130 lines per page. A [slug] route with the copy in a CMS or a data file would be one file.
I want to lay out why it is this way, and then show you the hole that only became visible when I measured it.
Why not one dynamic route
The obvious version is app/guides/[slug]/page.tsx reading from a map of slug to content. For a site like this one it gives up more than it saves.
Each page here is a list of sections, colocated with the prose it renders. The file for /features/multi-venue opens like this:
export const metadata: Metadata = contentMetadata("/features/multi-venue")
/**
* Hosts and venues. The numbers are the Ultimate row of utils/plan-limits.ts,
* maxAccounts 5 and maxConcurrentSessions 5, and the invite flow described is
* app/dashboard/settings/team-actions.ts.
*/
const SECTIONS: ContentSection[] = [ ... ]
That comment is the real argument. It is a note about where the facts in this page come from, written next to the sentences that make the claims, in the file somebody editing the page will have open. In a dynamic route, that page is a record in a collection, and the comment goes wherever comments go to die.
The second argument is the build. All 72 URLs are known at build time, so 70 of them are static HTML on a CDN. You can see which:
curl -sI https://pub-trivia.app/features/multi-venue | grep x-nextjs-prerender
# x-nextjs-prerender: 1
70 of the 72 answer with that header. The two that do not are / and /pricing, which render per request because they price in the visitor's own currency. With a dynamic segment I would have had to think about fallbacks, about what a request for an unknown slug does, and about revalidation. With one file per page, an unknown slug is a 404 and there is nothing to configure:
curl -s -o /dev/null -w '%{http_code}\n' https://pub-trivia.app/features/does-not-exist
# 404
What holds the 94 files together
Titles, descriptions, link labels and the breadcrumb trail do not live in the page files. They live in one registry, and the page asks for its own node:
export function requireNode(path: string): ContentNode {
const node = BY_PATH.get(path)
if (!node) throw new Error(`No content node registered for ${path}`)
return node
}
contentMetadata(path) calls that, so a page whose path is not in the registry does not render a broken <title>, it fails next build with a message naming the path. 69 files call it. A test in the other direction asserts every registry path is publicly reachable and in the sitemap, so you also cannot register a page the auth gate would bounce.
That is a decent closed loop. Here is the gap in it.
Every page writes its own URL twice
A content page names its path once for its metadata and once for its shell:
export const metadata = contentMetadata("/features/multi-venue")
// ...
<ContentPage path={"/features/multi-venue"}>
67 of the 69 do both. Nothing in the build or the test suite compares either string to the directory the file is sitting in.
So this compiles, passes, deploys, and serves:
app/features/multi-venue/page.tsx
contentMetadata("/features/private-hire")
Both strings resolve to real registry nodes, requireNode is happy, the types are fine. The page at /features/multi-venue would carry the title, description, canonical URL, breadcrumb and og:title of a different page. For a reader it is a page with the wrong heading. For a crawler it is two URLs claiming the same canonical, which is how pages get dropped from an index.
I checked all 69 against their directories. Zero mismatches today. That is the most fragile kind of correct: true by attention, re-verified by nobody, once per edit, forever.
And it is cheap to close, because the test suite already walks app/ to read metadata out of source for the pages that are not registry driven:
it('declares the path it is served at', () => {
for (const file of routeFiles(APP_DIR)) {
const source = readFileSync(file, 'utf8')
const declared = source.match(/contentMetadata\(\s*["']([^"']+)/)?.[1]
if (!declared) continue
const route = '/' + relative(APP_DIR, dirname(file)).replaceAll(sep, '/')
expect(declared, file).toBe(route === '/.' ? '/' : route)
}
})
Ten lines, no new dependency, and it turns a class of silent SEO bug into a red test. It is going in.
The better fix is to stop writing the string twice at all: the page's own path is derivable from its location, and a single const PATH at the top of each file would at least make the two copies one. That is a 67 file edit and a lint rule, which is why the test comes first.
The trade-off, honestly
file per page dynamic segment
--------------------------------- ---------------------------------
copy next to the page that shows copy in one collection
it, with provenance comments
all URLs known at build fallback and revalidate to decide
unknown URL is a 404 for free unknown URL is a decision
94 files, ~6 lines of preamble each one file
rename a URL: move a directory rename a URL: edit a string
path correctness: by eye (today) path correctness: structural
The last line is the one that matters. A dynamic route cannot be served at a path it does not know about, because the path is the input. A file per page can, because the path is written down twice next to a directory name that is the actual truth.
Check it yourself
Pick any two cluster pages and compare the URL you asked for with the canonical the page claims:
for p in features/multi-venue guides/how-to-be-a-good-quizmaster compare/kahoot-for-pub-quiz; do
echo -n "$p -> "
curl -s "https://pub-trivia.app/$p" | grep -o '<link rel="canonical" href="[^"]*"'
done
Every one should name itself. They do, today. The point of this post is that the thing making that true is me checking, and that is not a mechanism.
Top comments (0)