Build1 distinct publisher3 min readPublished
A Vite SPA shipped three title tags on every prerendered route while the build's dedupe step ran clean on all of them, because the nodes belong to a mounted component and removing them only queues their return.
The Engineer · Build desk
Evidence-backed comparisons of source perspectives and observed adoption signals. Read the methodology
Which Builder, Operator, and Investor concerns the observed source mix emphasized—not a truth score.
Follow any of these and your For You feed starts watching them — no settings page required.
Evidence, demonstrated adoption, hype gap, incentives, and confidence are assessed independently, each on its own current evidence. How these are measured.
Mechanism shown in code, surroundings on trust
The load of the argument sits on artifacts a reader can run: the page.evaluate() dedupe that has no effect on the serialized output, and the verify() function that throws when the shipped head disagrees with document.title. Everything around that is one person's word — the Search Console status, the per-page tag counts, the 399 tags stripped — and no second build anywhere shows React reinserting hoisted nodes in the gap between the two protocol calls.
One site, 23 routes, one author
The entire deployment record is the author's own build. React 19's head hoisting is treated as common practice and the post asserts the bug "bites anyone doing React 19 + prerendering", but this reporting counts no other team using the prerender-then-clean pattern and points to no bug report where the failure has surfaced elsewhere.
Hedged on damage, generous on reach
On consequence the author is his own sceptic: three titles is called embarrassing rather than fatal, since the correct one was already first, and the indexing problem is given back to domain age. The stretch is in the population. "I think this bites anyone doing React 19 + prerendering" is a claim about every team on that stack, and the support for it is one afternoon on one personal site.
Nothing on sale
No product, employer or maintainership appears anywhere in the post, and the author casts himself as the previous author of the broken code. What remains is reputational: a clean war story reads well on dev.to, which pulls gently toward a tidier account than a real afternoon of debugging usually produces.
Firm on React, thin on consequence
Two kinds of certainty are mixed. The React side matches documented behaviour and arrives with code — hoisted tags belong to a mounted component, so removing them queues their return — and document.title returning the first title element is checkable against the spec. The site-level numbers cannot be audited, and the question of what multiple canonicals cost today is left hanging where the account stops quoting Google.
build
Averaging 44 checks is how a noindex ships: the case against audit health scores1 distinct publisher
build
With CRA out of React's docs, the new project default is a rendering decision1 distinct publisher
build
A build-tool swap that took 70 days and 166 files, and the build was the easy part1 distinct publisher
build
A 5x publishing increase cost one site 1,000 indexed pages and every impression1 distinct publisher
Compiled by The EngineerSomething wrong?How this is made
React owns the nodes it hoisted. Calling `remove()` on them inside `page.evaluate()` unmounts nothing. It desynchronises the live DOM from the tree React believes it rendered, and the next commit restores what was deleted [10].
The duplicates get there in the first place because a client-side route transition briefly mounts one route's component before resolving to another, both render an `SeoHead`, both sets of tags get hoisted, and the unmounted one is not always cleaned up before the page is serialized [9]. The window the reinsertion lands in is structural: `page.evaluate()` and `page.content()` are separate CDP round trips, the page keeps running between them, and a lazy chunk landing or a queued state update or an effect firing is enough for a reconcile [11].
For in-page deletion to hold, the page would have to be quiescent at the instant of serialization, and the prerender script has no way to establish that. It awaits `domcontentloaded`, then a `waitForReactToMount` helper, then reads content [8]. A mounted component only confirms it exists, not that it has settled; the script never checks for the second condition. As the author of the writeup puts it, you are in a fight with the reconciler and the reconciler runs last [17].
The build's dedupe check runs against the live DOM rather than the file that ships [12], which is how it keeps a perfect pass rate while doing nothing.
The spec detail is what makes the rewrite reliable. `document.title` returns the child text content of the first `<title>` element in the document, which is also the one the browser shows and the one Google reads [14], so it answers which of three titles actually counts. `cleanHead` then keeps that first title and drops the rest, drops `link rel="canonical"` tags with no `href` and keeps exactly one, and for `meta[name]` or `meta[property]` drops empty-content placeholders where a populated tag exists for the same key [15]. The reason this cannot lose the same race twice: once the head has been serialized, it is a string, and there is no reconciler left to write back into it [16].
The volume is small and worth stating anyway. Seven head tags per page where three carry the meaning leaves four redundant, and the site has 23 pages, so a build in which every page is prerendered ships 92 tags nobody wanted [1].
There's a limit to what this evidence explains. Of 23 pages, 22 sat in Google Search Console as "Discovered - currently not indexed" [1], and the author's conclusion is that this is mostly a new domain that nobody links to [2]. The duplicate metadata was found on the way to that answer, not shown to have caused it.
This reaches your build only when several conditions in the source line up: React 19's hoisting of `title`, `meta` and `link` from anywhere in the component tree [6], a router that transiently double-mounts route components so two `SeoHead` outputs coexist [9], and a prerender step that serializes a live page rather than a string [8]. Drop any one of those and the collision cannot happen; keep all three and the dedupe step reports success on every page.
Ranked by verification strength, evidence, and original report placement.
22 of 23 pages on a site the author runs were listed in Google Search Console as "Discovered - currently not indexed".
Every prerendered page on the site was shipping with three <title> elements, two <link rel="canonical"> tags and two <meta name="robots"> tags.
The build already contained code whose entire job was to remove those duplicates; it ran on every page and was doing nothing.
The site is a Vite plus React 19 single-page application.
React 19 hoists <title>, <meta> and <link> rendered anywhere in a component tree up into <head>, which allows a per-route SeoHead component instead of react-helmet.
Because the hoisted tags only exist after JS runs, first-pass crawlers and social scrapers get an empty shell.
Distinct publishers with included, body-backed reporting in this cluster.
1 article · September 5, 2026