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 against next@canary), open http://localhost:3000 with the console open.
app/page.tsx has 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-party is gone from the DOM afterwards.
- Change
'div' to 'aside' in app/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.
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>.Minified React error #418(hydration mismatch).#third-partyis gone from the DOM afterwards.'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),
MetadataWrapperinpackages/next/src/lib/metadata/metadata.tsxrendersabove 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.jsforcesserveStreamingMetadata: true, andhtmlLimitedBotsonly 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 withclearContainerSparingly, 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
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.