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 <title> elements, two <link rel="canonical"> tags, and two <meta name="robots"> 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.
Here's why, because I think this bites anyone doing React 19 + prerendering.
The setup
Vite + React 19 SPA. React 19 hoists <title>, <meta> and <link> rendered anywhere in a component tree up into <head>, which means you can write a <SeoHead> component per route and skip react-helmet entirely:
export default function SeoHead({ title, description, canonical }) {
return (
<>
<title>{title}</title>
<meta name="description" content={description} />
<link rel="canonical" href={canonical} />
</>
);
}
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.
await page.goto(url, { waitUntil: 'domcontentloaded' });
await waitForReactToMount(page);
const html = await page.content();
await writeFile(outPath, html);
The duplicates
During a client-side route transition, React briefly mounts one route's component before resolving to another. Both render a <SeoHead>. 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:
await page.evaluate(() => {
const keep = new Map();
document.querySelectorAll('meta[name], meta[property]').forEach((m) => {
keep.set(m.getAttribute('property') || m.getAttribute('name'), m);
});
document.querySelectorAll('meta[name], meta[property]').forEach((m) => {
const k = m.getAttribute('property') || m.getAttribute('name');
if (keep.get(k) !== m) m.remove();
});
const canons = document.querySelectorAll('link[rel="canonical"]');
for (let i = 0; i < canons.length - 1; i++) canons[i].remove();
});
return await page.content(); // <- still has the duplicates
Read that last line again. The dedupe runs. Then page.content() returns HTML with the duplicates still in it.
Why it doesn't work
React owns those nodes. They're not inert markup that happens to sit in <head> — 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.
You are in a fight with the reconciler and the reconciler runs last.
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 fix: clean the string, not the DOM
The artifact you actually ship is a string. Nothing can re-inject into a string.
// Read what the page RESOLVED to. Don't mutate anything.
const expected = await page.evaluate(() => ({
title: document.title,
canonical: [...document.querySelectorAll('link[rel="canonical"]')]
.map((l) => l.getAttribute('href'))
.filter((h) => h && h.trim())
.pop() ?? null,
}));
const raw = await page.content();
const html = cleanHead(raw, expected, route); // pure string transform
document.title is the useful bit here. Per spec it returns the child text content of the first <title> 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 <title>, drop the rest; drop <link rel="canonical"> tags with no href, keep exactly one; for <meta name|property>, drop empty-content placeholders where a populated tag exists for the same key.
The part I'd actually recommend copying
Don't just clean it. Verify it, and fail the build if it's wrong.
function verify(html, expected, route) {
const head = html.slice(0, html.search(/<\/head>/i));
const titles = head.match(/<title\b[^>]*>[\s\S]*?<\/title>/gi) ?? [];
if (titles.length !== 1) {
throw new Error(`${route}: ${titles.length} <title> after cleaning`);
}
const text = titles[0].replace(/<\/?title\b[^>]*>/gi, '').trim();
if (text !== expected.title.trim()) {
throw new Error(`${route}: shipped "${text}", page renders "${expected.title}"`);
}
const canons = head.match(/<link\b[^>]*\brel=["']?canonical["']?[^>]*>/gi) ?? [];
if (canons.length > 1) {
throw new Error(`${route}: ${canons.length} canonicals survived`);
}
}
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.
Was it actually hurting anything?
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.
Google's 5 common mistakes with rel=canonical says: "When more than one is specified, all rel=canonical links will be ignored." That's unambiguous — but it's a 2013 post, and Google now displays an outdated-content banner on it. The current canonicalization docs don't restate the rule either way.
So the honest version: the last explicit thing Google said about two canonicals on a page is that both get thrown out, and nothing since has replaced it. My pages were declaring an empty <link rel="canonical"> immediately followed by the real one. I'm not going to gamble a whole site's canonical setup on that being fine now.
Three things I'd take away
- If a cleanup step touches DOM that a framework owns, assume the framework wins. React 19's head hoisting is a rendering feature, not a markup convenience.
- Do the transform on the artifact you ship. For prerendering, that's the serialized string, and it's the only stage where nothing can undo you.
- A build step with no assertion is decoration. This code ran on every page of every deploy for weeks and produced nothing. Nobody noticed because nothing was checking.
I run CipherExam, a certification-prep tool for PMP and the CompTIA exams — this is from its prerender pipeline. If you're doing React 19 + prerendering and you haven't looked at your serialized <head> lately, go look at it.
Top comments (0)