Back to News & Insights
Web Development September 5, 2026 · 5 min read

Why my prerendered pages shipped with three <title> tags

I spent an afternoon working out why 22 of 23 pages on a site I run were sitting in Google Search...

Why my prerendered pages shipped with three <title> tags

I spent an afternoon working out why 22 of 23 pages on a site I run were sitting in Google Search Console as "Discovered — currently not indexed."

The answer turned out to be mostly "nobody links to you, you're a new domain, get in line." That part isn't interesting.

What was interesting is what I found on the way there: every prerendered page on the site was shipping with three elements, two tags, and two tags. There was already code in the build whose entire job was to remove those duplicates. It ran on every page. It was doing nothing.

Vite + React 19 SPA. React 19 hoists , and rendered anywhere in a component tree up into , which means you can write a component per route and skip react-helmet entirely:

That's genuinely nice. It also means the tags only exist after JS runs, so first-pass crawlers and social scrapers get an empty shell. The usual fix: a prerender step that runs after vite build, walks every public route in headless Chromium, and writes the fully-rendered DOM to dist/{route}/index.html.

During a client-side route transition, React briefly mounts one route's component before resolving to another. Both render a . Both sets of tags get hoisted. The unmounted one doesn't always get cleaned up before you serialize.

So the previous author (me) did the obvious thing — strip the duplicates in the page before serializing:

Read that last line again. The dedupe runs. Then page.content() returns HTML with the duplicates still in it.

React owns those nodes. They're not inert markup that happens to sit in — they're the rendered output of a mounted component, and React holds references to them. Deleting them out from under React doesn't unmount anything. It just puts the DOM out of sync with React's idea of the DOM, and the next commit puts them back.

page.evaluate() and page.content() are two separate CDP round-trips. Between them, the page keeps running. Any pending render — a lazy chunk landing, a state update, an effect firing — is enough for React to reconcile and reinsert everything you just deleted.

It also fails silently. The dedupe code executes without error. If you check the DOM immediately afterward it looks correct. Only the serialized file on disk is wrong, and nobody diffs the serialized file.

The artifact you actually ship is a string. Nothing can re-inject into a string.

document.title is the useful bit here. Per spec it returns the child text content of the first element in the document — which is also the one browsers show and the one Google reads. So it's not just a convenient value, it's the authoritative answer to "which of these three titles actually counts."

cleanHead then does the boring part on the string: keep the first , drop the rest; drop tags with no href, keep exactly one; for , drop empty-content placeholders where a populated tag exists for the same key.

The original bug wasn't that the dedupe was wrong. It was that nothing checked whether it had worked. A cleanup step with no assertion is a comment that costs CPU.

With the check in place the build either produces one correct title and one correct canonical per route, or it stops. Across 23 routes it stripped 399 duplicate and empty tags.

Honest answer: the titles, probably not much. The correct one was already first, which is what document.title and Google both use. Three of them is embarrassing, not fatal.

The canonicals are the ones I'd lose sleep over, and this is worth being precise about because the internet repeats it carelessly.

Want to discuss this further?

Book a free strategy call with our team to see how these insights apply to your specific business goals.

Book a consultation