Streamed metadata wrapper is a <div> as the first child of <body>, so a third-party <div> prepended before hydration breaks hydration and gets deleted
Nobody has claimed this yet.
- Dominant language
- JavaScript
- Stars
- 142k
- Forks
- 32.4k
- Avg merge
- 2d 14h
- Merged PRs (30d)
- 351
Description
Link to the code that reproduces this issue
https://github.com/nickkosmides/next-metadata-wrapper-prepend-repro
To Reproduce
npm install && npm run build && npm run start(verified againstnext@canary), open http://localhost:3000 with the console open.app/page.tsxhas an inline script that prepends<div id="third-party">to<body>during parsing — before hydration — the way consent managers (InMobi/Quantcast Choice:#qc-cmp2-container), chat widgets and some extensions do when their script finishes before React does. The root layout's own first body child is an<a>skip link, not a<div>.- Console:
Minified React error #418(hydration mismatch).#third-partyis gone from the DOM afterwards. - Change
'div'to'aside'inapp/page.tsx: no error, node kept.
(An earlier copy of this report, #98498, was auto-closed for lacking a repository link; this one supersedes it.)
Current vs. Expected behavior
Current: with streaming metadata (the default), MetadataWrapper in packages/next/src/lib/metadata/metadata.tsx renders
<div hidden>
<MetadataBoundary>
<Suspense name="Next.Metadata">…</Suspense>
</MetadataBoundary>
</div>
above the root layout, so React places it as the first child of <body> on every page. It is in every prerendered document too: next/dist/export/worker.js forces serveStreamingMetadata: true, and htmlLimitedBots only affects request-time renders.
React 19 hydrates <body> children by tag name and skips a foreign node only when its tag differs (canHydrateInstance, inRootOrSingleton). So any third party that prepends a <div> to <body> before hydration finishes has that <div> claimed for Next's wrapper; the wrapper's expected child (a Suspense comment) is not found inside it; hydration fails at the root; React regenerates the document with clearContainerSparingly, which removes every element in <body> that is not a script/style/stylesheet link — the third party's DOM is deleted.
An application cannot avoid this: the wrapper precedes anything the root layout renders, so an app whose own first body child is not a <div> is exposed purely because of the framework's wrapper. React's source describes the tag-name skip as intentional and a prepended <div> as "an edge case"; Next's wrapper turns that edge case into the default for every App Router site.
Expected: a third-party element prepended to <body> is skipped by hydration, as React already does for any tag other than the one Next renders first. React's requirement (#77620) is a host element around the top-level Suspense boundary, not a <div>; a custom element such as <next-metadata hidden> satisfies it, renders and hydrates identically, and is a tag nothing prepends. Next already uses <next-route-announcer> the same way. Patch: #98499.
Provide environment information
Next.js: canary (16.4.0-canary.25) in the repro; 16.3.4 in production
React: 19.2.8
Node: 24.15.0, macOS 15 / Linux (production)
Browsers: Chromium 141, Safari 18, in-app browsers
Which area(s) are affected?
Metadata, Hydration
Which stage(s) are affected?
next build (local), next start (local), Vercel/other hosting (production)
Additional context
Seen on a production site (Next 16.3.4) with a consented InMobi CMP: the CMP's corner "privacy" tab disappeared intermittently, and every such load carried a #418. Controlled Playwright run against that site: foreign <div> prepended before hydration → #418 on 2/2 loads and the node deleted; foreign <aside> in the same place → 0/2, node kept. Hydration finished at ~500–650 ms and the CMP's container arrived at ~900–1250 ms on a fast desktop, so the race is lost on slower devices or warm CMP caches — which is why it looks random in the wild.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with the linked reproduction and the wrapper in packages/next/src/lib/metadata/metadata.tsx, then inspect the related export path in next/dist/export/worker.js. Run the reported build/start steps and compare the div and aside cases; done means the prepended third-party element survives hydration without error #418.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- javascript, next.js, react
- Domain
- frontend, web-dev
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Stale
- Clarity
- Clearly specified
- Newbie friendliness
- 25/100