A page title is a contract with a search result, and the contract is about length. Over roughly 60 characters it gets truncated; a description over about 160 goes the same way. TypeScript will happily let you write a 200 character description, your build will pass, your page will render, and you will find out from a screenshot of a search result six weeks later.
So we test it. The interesting part is not the assertion, it is that the test never imports a single page.
import { readFileSync, readdirSync } from 'node:fs'
const APP_DIR = join(process.cwd(), 'app')
function routeFiles(dir: string): string[] {
const found: string[] = []
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name)
if (entry.isDirectory()) found.push(...routeFiles(full))
else if (entry.name === 'page.tsx' || entry.name === 'layout.tsx') found.push(full)
}
return found
}
That walk finds 100 files in our app directory. The reason the test reads them with readFileSync instead of importing them is in the comment at the top of the file:
These are read out of the source rather than imported: a page module pulls in
Supabase and the rest of the server runtime at import time, and none of that
has anything to do with the length of a string.
Which is the whole argument. import('./app/pricing/page') in a Next.js app is not a cheap operation. It evaluates the module, which evaluates its imports, which means a database client, a Stripe client, environment variable access and whatever else the page happens to touch. To measure a string. In a suite that is supposed to run in under a second.
Reading the file as text has a cost, which is that you are now parsing TypeScript with regular expressions, and we will get to what that cost actually is further down. It is smaller than people assume, because the thing being extracted is a string literal in a known position in a file written by the same team, not arbitrary source.
Finding the block
Two functions. The first slices out the metadata export, the second pulls a field out of it:
/**
* The `export const metadata = ...` declaration, as source text.
*
* Ends at the first line that closes it at column zero: `}`, `})` or either
* with a semicolon, which is what both spellings in this app produce.
*/
function metadataBlock(source: string): string | null {
const start = source.search(/^export const metadata\b/m)
if (start === -1) return null
const rest = source.slice(start)
const end = rest.search(/^\}\)?;?$/m)
return end === -1 ? rest : rest.slice(0, end)
}
function stringField(block: string, field: string): string | null {
const match = block.match(new RegExp(`\\b${field}:\\s*(["'])((?:\\\\.|(?!\\1).)*)\\1`))
return match ? match[2].replace(/\\(['"])/g, '$1') : null
}
The "closes at column zero" trick is what makes this work without a parser. A nested object inside the metadata declaration is indented; the line that ends the declaration is not. It handles both spellings we use, the plain object literal ending in } and the pageMetadata({ ... }) call ending in }), and it will fail loudly rather than quietly if somebody invents a third.
stringField is the fiddly one. The capture group matches the opening quote, then anything that is not that same quote unless escaped, then the same quote again. That backreference is the only reason title: "Don't panic" and title: 'Say "hello"' both come out right.
Not testing the 69 pages that are already tested
Most of our public pages do not write their metadata by hand. They get it from a content registry, a typed module holding one node per page with its title, heading, description, date and sideways links, and the page calls contentMetadata('/its/path'). So the test skips them:
const block = metadataBlock(source)
// Pages routed through the registry are covered by registry.test.ts.
if (!block || block.includes('contentMetadata(')) return null
The arithmetic across our 100 route files comes out as:
-
69 call
contentMetadata(), and are covered by a different suite of 21 tests that checks the same length budget plus the whole internal link graph - 13 write their own metadata, which is this test's job
- 18 export none at all
That last group is not an oversight either. Fourteen of the eighteen are pages nobody is allowed to index: the eleven dashboard pages, the player page, the post-checkout page and one error page in the invite flow, each sitting under a segment whose layout already sets robots: { index: false }. A page that will never appear in a search result does not need a title tuned for one. The remaining four are structural layouts: one wraps the legal pages in a light-theme shell with their own header, and one of the others is literally return <>{children}</>. The page inside each of them is the thing with the title.
The guard that matters more than the assertions
Here is the test that is not about metadata at all:
it('finds the pages that set their own metadata', () => {
// A refactor that changes how these are declared should fail loudly here
// rather than quietly leave the suite asserting nothing.
expect(PAGES.length).toBeGreaterThan(5)
})
Every test that discovers its own inputs needs one of these. The failure mode of a source-reading test is not a false negative on one page, it is the day somebody changes export const metadata to export const metadata: Metadata = buildMeta(...), the regex matches nothing, PAGES becomes empty, and both length tests pass by iterating over zero items. Green suite, zero coverage, no signal. The loop-based assertions cannot tell you that, so a separate assertion counts the inputs.
Measuring the budget after the template, not before
The titles in our page files are short, because the root layout appends the site name:
title: {
default: "PubTrivia | Run Live Pub Quiz Nights",
template: "%s | PubTrivia",
}
Which means the number in the file is not the number a crawler sees, and a test asserting on the raw string is testing the wrong string:
const rendered = page.title.includes(SITE_NAME)
? page.title
: `${page.title} | ${SITE_NAME}`
expect(
rendered.length,
`${page.file} renders as a ${rendered.length}-char title: ${rendered}`
).toBeLessThanOrEqual(TITLE_LIMIT)
| PubTrivia is 12 characters, so the template spends a fifth of the budget before the page has said anything. SITE_NAME is read from the shared publisher module rather than typed in, so renaming the product does not leave a test asserting against the old name.
The custom message in the second argument is there because of what the failure looks like without it: expected 183 to be less than or equal to 160, with no indication of which of thirteen files is at fault. With it, the failure names the file, the count and the string.
You can check the rendered numbers against the live site in one line, and they match the source exactly:
curl -s https://pub-trivia.app/faq \
| grep -o '<title>[^<]*</title>' | sed 's/<[^>]*>//g' | tr -d '\n' | wc -c
# 38
| Page | Title | Description |
|---|---|---|
/login |
19 | 99 |
/legal/privacy |
26 | 95 |
/too-many-requests |
29 | 72 |
/forgot-password |
31 | 69 |
/signup |
36 | 129 |
/faq |
38 | 150 |
What the regexes cannot see
One of the thirteen hand-written pages yields null for both fields, and the tests skip it:
if (page.title === null) continue
It is the player route's layout, whose entire metadata export is robots: { index: false, follow: false }. No title, nothing to measure, correctly skipped.
But that continue is also the hole. A page that built its title from a template literal or a constant rather than a quoted string would come back as null and be skipped in exactly the same silent way, and the count guard would not notice because the file is still in PAGES. The honest description of this suite is that it enforces the budget on every title written as a plain string literal, and that the convention of writing them that way is load-bearing. We accept that, because the alternative is a real parse of 100 TypeScript files to check thirteen strings, and because the 69 registry pages, which are the ones that actually appear in search results, are typed data checked by a suite that cannot miss a node.
Three tests, 146 milliseconds, no database connection, no build step.
The pages it guards
-
Our FAQ and the privacy policy are two of the thirteen. View source and the
<title>is what the test measured. - Pricing is one of the 69 the other suite covers, which is why its description runs to 156 characters and stops.
- The rate-limit page is in the set too, which is a slightly funny thing to tune for search, and it is in there because the rule is cheaper than the exception.
The product is a live pub quiz run from the phones in the room, free tier, no card.
Top comments (0)