close
Skip to content

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 #98500

Description

@nickkosmides

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions