DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Your title template spends ten of your sixty characters, and nobody counts them

Google truncates a <title> at roughly 60 characters and a meta description at roughly 160. Every SEO checklist says so. Most teams treat it as a thing you eyeball in review.

Notifio's marketing site has about 38 pages, and 28 of them do not exist as files. They are generated from data: fifteen per-site alert pages, five comparison pages, five guides, and three audience pages. Nobody is eyeballing 38 titles, and the type that keeps those generated pages honest can insist a title field exists but cannot insist it is short enough to survive a search result.

So the budget is a test. node --test, no framework, runs on npm test. This post is about the three things that made it harder than "check .length", because all three are things I got wrong first.

1. The budget you check is not the budget that ships

Here is the root layout:

title: {
  default: "Notifio – Rental Listing Alert Monitor",
  template: "%s | Notifio",
},
Enter fullscreen mode Exit fullscreen mode

Every page except the home page renders its title through that template. A page declaring title: "Rightmove Alerts" ships Rightmove Alerts | Notifio.

Ten characters, on every page, spent before the page says anything. That is a sixth of the budget, and it is invisible at every place a human would look: it is not in the page file, it is not in the string you are counting, and it only appears in the rendered HTML.

/** 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", "");
}
Enter fullscreen mode Exit fullscreen mode

The suffix is read out of the layout rather than hardcoded in the test. If somebody rebrands the template to %s | Notifio Alerts, every page's real budget shrinks by seven characters, and the test that enforces the budget should discover that by itself rather than sit there confidently checking against the old suffix.

From the test's own comment:

Hand-counting that is what let three titles drift to exactly 60.

Three. Which is the number that convinced me this belonged in CI rather than in a review checklist.

2. Globbing beats listing, because the point is the page you have not written yet

The data-driven side is easy, since the collections are already arrays:

const collections = [
  ["/alerts", ALERT_SITES],
  ["/compare", COMPARE_TARGETS],
  ["/for", AUDIENCE_PAGES],
  ["/guides", GUIDE_PAGES],
] as [string, readonly { slug: string; title: string; description: string }[]][];
Enter fullscreen mode Exit fullscreen mode

But alert sites are split across market files, uk.ts, netherlands.ts, europe.ts, and those get imported by directory rather than through the barrel:

/**
 * 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());
Enter fullscreen mode Exit fullscreen mode

The immediate reason is a module resolution annoyance. The better reason is the second sentence. A test that imports an explicit list of files tests exactly the files somebody remembered to add to it, and the page most likely to have a 64 character title is the one written last week by someone who had not read this test.

Same idea on the static side, which walks src/app for every page.tsx and skips api:

it("finds the static pages to check", () => {
  assert.ok(pages.length >= 10, `only found ${pages.length} pages under src/app`);
});
Enter fullscreen mode Exit fullscreen mode

That assertion is there to catch the failure mode that makes globbing dangerous: if the walk breaks, or the directory moves, you do not get a failure, you get a green run over zero pages. A discovery-based test needs a test that discovery worked. Otherwise the day it silently finds nothing is the day it starts passing forever.

3. Unparseable has to fail, not pass

The static pages are the awkward ones. Their metadata is an exported object literal in a .tsx file full of JSX and @/ aliases, so importing them from a bare Node test is not happening. The test reads the source:

/**
 * 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 {
Enter fullscreen mode Exit fullscreen mode

Regex-parsing source code is exactly the sort of thing that deserves the eyebrow you are currently raising. What makes it acceptable is the last clause of that comment, and it is the whole design:

const title = literalField(block, "title");
assert.ok(title, `could not read a string title out of ${file}`);
Enter fullscreen mode Exit fullscreen mode

If the parser cannot read the title, the test fails. It does not skip, it does not warn. So the parser being limited is self-correcting: the first person to write a title the parser cannot handle gets a red build telling them exactly which file, and they either simplify the title or improve the parser. The thing that can never happen is a page quietly exempting itself by being too clever.

Contrast with the version I nearly wrote, where an unreadable field returns null and gets skipped. That version's coverage silently decays, and worse, it decays specifically on the unusual pages, which are the ones most likely to be wrong.

Two deliberate asymmetries alongside it:

// A page may omit `description` and inherit the root layout's.
check(route, title, literalField(block, "description"));
Enter fullscreen mode Exit fullscreen mode

A missing description is legal, because inheriting the root one is a real choice. A missing title is not.

// Dynamic routes build their metadata from the collections above.
if (source.includes("generateMetadata")) return;
Enter fullscreen mode Exit fullscreen mode

A page that generates metadata is skipped here, because its content was already checked through the collections at the top of the file. This one is a genuine seam. It is correct today because every generateMetadata on the site derives from one of those four collections, and it would quietly stop being correct if someone wrote one that did not. If I revisit this file, that is the line I would tighten first.

And the root layout checks its own defaults, which is the page nobody thinks of as a page:

it("the root layout's own defaults fit too", () => {
Enter fullscreen mode Exit fullscreen mode

What it actually buys

Not better SEO copy. A test cannot tell you a title is dull. What it buys is that a specific, boring, entirely mechanical failure, the one where your search result reads Rightmove Alerts, Skip the Email Send Qu..., cannot reach production, and nobody has to hold "remember the template eats ten characters" in their head while writing.

That is the category of rule worth automating: objective, easy to violate, invisible in review, and expensive in a place you will not look for months.

Go and look

The pages this is protecting, so you can check the titles in your own search results:

Related, on the same site: the type that refuses to let fifteen near-identical pages be identical, and why a 200 from IndexNow does not mean it read your key.

Top comments (0)