Every content page on PubTrivia has one sentence that describes it, written once, in the registry entry that also carries the page's title, its path and its last-updated date. The guide to pub quiz formats has this one:
The standard six-round format with real timings, plus three alternatives for a shorter night, a louder room and a one-off. Where to put the hard round.
151 characters. I wanted to know how many times that string appears in the bytes the browser actually receives, so I counted:
$ curl -s https://pub-trivia.app/guides/pub-quiz-format \
| grep -o 'The standard six-round format' | wc -l
10
Ten. Here is where each one is, in document order:
| # | Where | Who reads it |
|---|---|---|
| 1 | <meta name="description"> |
search engines |
| 2 | <meta property="og:description"> |
link preview scrapers |
| 3 | <meta name="twitter:description"> |
the same scrapers again |
| 4 |
"@type":"Article", description field |
structured data parsers |
| 5 | a <p> under the <h1>
|
a person |
| 6-10 | the React flight payload, repeating 1 to 5 | the client router |
One of the ten is for a human.
The three in the head are two decisions and a framework
The meta description and the Open Graph one come from a single helper, pageMetadata in lib/seo.ts, which takes a description once and writes it twice:
return {
title: absoluteTitle ? { absolute: title } : title,
description,
alternates: { canonical: path },
openGraph: {
title: fullTitle,
description,
type: 'website',
url: path,
},
}
The third one, twitter:description, is not in that object and nothing in the repository writes it. Next.js emits it, filling the Twitter card from the description and Open Graph fields when a page does not declare a Twitter block of its own. It is a sensible default and it is worth knowing it is happening, because it means two thirds of the head copies of this string are mine and one third is the framework's.
The helper is called from one place for content pages:
export function contentMetadata(path: string): Metadata {
const node = requireNode(path)
return pageMetadata({
path: node.path,
title: node.title,
description: node.description,
absoluteTitle: node.path === '/',
})
}
Its docstring explains why it exists, and the reason is the one that matters here: "the words in the title, the words in the link preview and the words another page uses when linking here are one string in one place. They were three copies on the pages that predate the registry, and two of them had already drifted."
Which is the distinction I would make to anybody auditing duplication in a Next.js head. Three copies in the delivered HTML are fine. Three copies in the source are how a page ends up telling Google one thing and Facebook another.
The fifth copy is the interesting one
Copy five is a visible paragraph, rendered by the content template directly under the heading:
<h1 className="...">{node.heading}</h1>
<p className="text-muted-foreground mb-4 text-lg leading-relaxed">
{node.description}
</p>
So the meta description is not crawler-only copy. It is the first thing a reader sees. I checked that across the whole sitemap: on 67 of 72 pages the description appears verbatim in the visible text, and the five exceptions are the homepage, the about page and the three legal documents, all of which are hand-written layouts that do not go through the content template.
That is a constraint worth adopting on purpose. A meta description written in a separate field, for search results only, can promise anything, and nobody proofreads it against the page. A meta description that is also the page's opening sentence cannot drift, cannot oversell, and gets read by the person who clicked, which means it gets fixed when it is wrong. The cost is that it has to work in two registers at once: as a snippet in a results list, and as a lede under a heading. The 151-character one above does both. Some of our weaker ones read slightly like a snippet, and that is the tell for a sentence that was written for the wrong audience.
The structured data adds one, or two
Copy four is inside the JSON-LD. The guide pages emit an Article, and the same description fills its description field. One page emits two objects, and gets two copies:
"@type":"Article","headline":"How to host a pub quiz","description":"...
"@type":"HowTo","name":"How to host a pub quiz","description":"...
That guide is the heaviest page on the site by this measure, with 12 occurrences of its own description. The distribution across all 72 pages:
| Copies per page | Pages |
|---|---|
| 6 | 5 |
| 8 | 39 |
| 10 | 27 |
| 12 | 1 |
The 27 at ten are the pages that carry Article. The 39 at eight do not. The five at six are the hand-written ones with no visible lede and no structured description.
What it costs
Totals over the whole sitemap: 72 distinct descriptions, 10,798 characters of writing, 624 occurrences in the delivered HTML, 94,144 bytes of sentence shipped, of which 83,346 bytes are duplicates of a string already on the page.
83 KB sounds worse than it is. These pages are brotli-compressed by the edge, and a repeated string is the single most compressible thing in a document: the whole site is 5.08 MB uncompressed and 810 KB over the wire, and duplicated prose is most of why that ratio is 6.28. The copies are nearly free in transfer. They are not free in parse, and they are not free in the flight payload, which carries every sentence on the page a second time for exactly this kind of reason: whatever the server rendered, the payload repeats so the client router can take over.
The census the counting turned into
Having the whole head in a file, I measured the rest of it:
- All 72 descriptions are distinct. No boilerplate, no shared template sentence.
- None is over 160 characters. 31 land between 155 and 160, 36 between 140 and 154, 5 under 140. The shortest is the about page at 87.
- Only 10 of 72 contain the word "PubTrivia", because a description that spends its first word on the brand spends it on something the result already shows.
- All 72 titles are distinct, and 71 of them end in
| PubTrivia(the homepage opts out, since it names itself). They run from 17 to 48 characters, so none is near the length a results page truncates, and the suffix costs 12 of those characters. - The head itself is 4,472 to 6,200 bytes, with 24 meta tags on 65 pages and 29 or 30 on the seven that still carry an og:image.
And one thing that is wrong, found by reading the file I was quoting from. The docstring in lib/seo.ts describes the root layout's title template, and it spells the separator as an em dash. The root layout carries this:
title: {
template: "%s | PubTrivia",
}
A pipe, and it has been a pipe since a house-style pass took every em dash out of anything a user can read. Every rendered title on the site uses it, and __tests__/content/registry.test.ts asserts that exact format. The code changed, the test changed, and the comment describing both did not. It is the second stale comment I have found this week by quoting my own source in a blog post, which is a decent argument for the habit.
Check it yourself
No account needed:
# count the copies on any content page
curl -s https://pub-trivia.app/guides/pub-quiz-format \
| grep -o 'The standard six-round format' | wc -l
# see the three head copies and the structured data one
curl -s https://pub-trivia.app/guides/pub-quiz-format \
| grep -o '<meta name="description"[^>]*>\|<meta property="og:description"[^>]*>\|<meta name="twitter:description"[^>]*>'
Then open the guide and read the paragraph under the heading, which is the same sentence doing the only one of its five jobs that involves a reader. The guides index lists fourteen more, and the free tools at the end of them run entirely in your browser.
Top comments (0)