Notifio sells a desktop app that watches rental search pages, and the website around it is about forty pages: a hub and a page per site it can monitor, a page per competitor, a few guides, and the usual static set. Every one of those needs a <title> short enough to survive a search result and a description short enough to survive a snippet.
Roughly 60 characters for the title, roughly 160 for the description. Those numbers are not laws, they are where Google starts truncating, and a truncated title is a title you did not write.
The problem is not knowing the numbers. It is that the number you have to count is not the one you typed.
Ten characters you did not type
The root layout:
export const metadata: Metadata = { metadataBase: new URL(APP_URL), title: { default: "Notifio – Rental Listing Alert Monitor", template: "%s | Notifio", }, // ... }; So a page that declares title: "Rental Alert Tools Compared – Prices & Trade-offs" ships a 59 character title, not a 49 character one. Ten characters of the budget are spent before the page gets a say, on every page but the home page.
I counted that by hand for a while, with a comment above each metadata block recording the arithmetic:
// title 59/60 chars rendered (the "| Notifio" template adds 10) | description 153/160 chars export const metadata = { title: "Rental Alert Tools Compared – Prices & Trade-offs", // ... }; Those comments are still there and I still like them, because they put the budget next to the string being spent out of it. What they are not is enforcement. Three titles drifted to exactly 60 rendered characters, which is over the line I had set myself, and all three had a comment above them claiming otherwise. A comment is a snapshot of what somebody believed once.
So it became a test. The interesting part is not the assertion, it is that the pages are not all the same kind of thing.
Two kinds of page, two kinds of check
Most of the site is data-driven: the per-site alert pages, the comparison pages, the guides and the audience pages are all TypeScript objects in lib/, rendered through one template each. Those are easy, because a test can import them:
import { COMPARE_TARGETS } from "../../src/lib/compare/targets.ts"; import { AUDIENCE_PAGES } from "../../src/lib/articles/audiences.ts"; import { GUIDE_PAGES } from "../../src/lib/articles/guides.ts"; Except for the fourth collection, which could not be imported the same way:
/** * The alerts barrel uses extensionless imports that Node cannot resolve, so the * site files are loaded straight from the directory. Globbing rather than * listing them also means a new market file is covered the day it is added. */ const SITES_DIR = path.join(import.meta.dirname, "../../src/lib/alerts/sites"); const ALERT_SITES = ( await Promise.all( readdirSync(SITES_DIR) .filter((name) => name.endsWith(".ts")) .map((name) => import(path.join(SITES_DIR, name))), ) ).flatMap((module) => Object.values(module).flat()); The tests run under node --test with no bundler, which is a decision I wrote about separately in node --test runs our TypeScript, and the price is that @/ does not exist. The price shows up here: lib/alerts/index.ts re-exports ./sites/netherlands with no extension, Next resolves that and Node does not, so the barrel is unusable from a test. Going around it through the directory turned out better than fixing the import, because the glob picks up a new market file automatically. The day sites/nordics.ts appears, it is covered, and nobody has to remember to add it to a list in a test.
The static pages are the awkward ones. A page.tsx under src/app imports JSX, next/link, path aliases and components; importing one into a bare Node test is not happening. But all any of them does with metadata is export an object literal of strings. So the test reads the file as text:
/** * Pulls a key's value out of a `Metadata` object literal. Only handles plain * string literals and `+`-free multi-line strings, which is all any page uses; * anything else returns null and is reported as unparseable rather than passed. */ function literalField(block: string, key: string): string | null { const match = block.match(new RegExp(`\\n ${key}:\\s*([\\s\\S]*?),\\n \\w`)); if (!match) return null; const parts = match[1].match(/"(?:[^"\\]|\\.)*"/g); if (!parts) return null; return parts.map((p) => JSON.parse(p) as string).join(""); } Yes, that is a regex reading TypeScript. I want to defend it briefly, because "do not parse code with regex" is good advice that I am ignoring on purpose.
The regex is anchored on two-space indentation at the start of a line, which is what a top-level key in a Metadata literal looks like under this repo's formatter. The capture ends at the next such key. Every double-quoted run inside it is handed to JSON.parse, so escapes are handled by something that actually knows the grammar for strings, and multi-line descriptions split across two string literals are concatenated. If any of that fails, the function returns null, and null is not treated as "fine":
const title = literalField(block, "title"); assert.ok(title, `could not read a string title out of ${file}`); A page that writes its title in a way this cannot read fails the test with a message saying so. That is the property that makes a sloppy parser acceptable: it cannot quietly approve something it did not understand. The failure mode is a build break and a five minute fix, not a silent gap in coverage.
The description is allowed to be absent, because a page may legitimately inherit the layout's:
// A page may omit `description` and inherit the root layout's. check(route, title, literalField(block, "description")); The template is read, not assumed
The suffix is not hardcoded in the test:
/** The suffix the root layout's title template appends to every child page. */ function titleTemplateSuffix(): string { const layout = readFileSync(LAYOUT, "utf8"); const match = layout.match(/template:\s*"(.*?)"/); assert.ok(match, "root layout no longer declares a title template"); const [, template] = match; assert.ok(template.includes("%s"), `unexpected title template: ${template}`); return template.replace("%s", ""); } This is the piece I would keep if I had to throw the rest away. If the template becomes "%s | Notifio Rental Alerts", every page on the site just got eighteen more characters of overhead, and a test with " | Notifio" baked into it would carry on passing while the titles truncated in production. Reading it from the layout means a template change is immediately a test change, and the test tells you which pages no longer fit.
The two assertions above it are the same idea pointed at the test's own assumptions. If someone removes the template, or switches to a function form, the test says the layout no longer declares a title template rather than silently comparing against an empty suffix.
The assertion, and the one that is easy to forget
function check(label: string, title: string, description: string | null) { const full = rendered(title); assert.ok( full.length < TITLE_MAX, `${label}: title is ${full.length} chars, must be under ${TITLE_MAX}`, ); if (description !== null) { assert.ok( description.length < DESCRIPTION_MAX, `${label}: description is ${description.length} chars, must be under ${DESCRIPTION_MAX}`, ); } } Strictly less than, not less than or equal. The three titles that drifted were at exactly 60, and 60 is where truncation starts rather than where it is safe, so the limit is exclusive. The label in the message is the route, so a failure names the page without anyone having to work out which file the test was on.
And the one that is easy to leave out:
it("finds the static pages to check", () => { assert.ok(pages.length >= 10, `only found ${pages.length} pages under src/app`); }); plus, in the collection loop:
assert.ok(entries.length > 0, `${prefix} has no entries to check`); A test that iterates an empty list passes. If src/app moves, or the directory walk starts skipping something, or a collection export is renamed, the suite goes green while checking nothing at all. These two lines are the difference between "every page fits" and "every page I happened to find fits", and the second one is not a claim worth making.
The directory walk is small and skips exactly one thing:
function pageFiles(dir: string): string[] { const found: string[] = []; for (const item of readdirSync(dir, { withFileTypes: true })) { const full = path.join(dir, item.name); if (item.isDirectory()) { if (item.name === "api") continue; found.push(...pageFiles(full)); } else if (item.name === "page.tsx") { found.push(full); } } return found; } API routes have no metadata and no title, so they would all fail the "exports no metadata" assertion for no reason. Dynamic routes are skipped inside the test instead, by looking for the function that would build their metadata:
// Dynamic routes build their metadata from the collections above. if (source.includes("generateMetadata")) return; Their content has already been checked, as data, by the first half of the file. Checking the template that renders it would mean checking a string with a hole in it.
Where it is deliberately wrong
The test counts the home page's title as ten characters longer than it is. Next.js applies a title template to child segments, not to the page that sits alongside the layout declaring it, so notifio.app ships a 38 character title and the test calls it 48. You can check: the <title> on notifio.app has no | Notifio on the end, while notifio.app/compare does.
I left that alone. The error is in the conservative direction, it affects exactly one page, and the special case needed to fix it would be a special case in the one function every other page relies on. A budget check that is ten characters pessimistic about one page is fine. A budget check with a branch in it that nobody remembers the reason for is not.
If you want to see the tightest result in production, /compare is the page that lives closest to the line: 49 characters of title plus the ten the template spends, which is 59 of the 60. The pages it links to are the comparison set, and the other two collections the same test covers are the per-site alert pages and the guides. Between them that is around forty titles and forty descriptions, none of which anybody counts by hand any more.
