vercel / vercel/next.js

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

Open
#98,500 0 comments 0 reactions 0 assignees View on GitHub

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
  1. npm install && npm run build && npm run start (verified against next@canary), open http://localhost:3000 with the console open.
  2. 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>.
  3. Console: Minified React error #418 (hydration mismatch). #third-party is gone from the DOM afterwards.
  4. 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.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.