Skip to content

Fix PPR resume metadata tree mismatch for bot user agents#94630

Open
pnodet wants to merge 1 commit into
vercel:canaryfrom
pnodet:fix/ppr-resume-metadata-bot-mismatch
Open

Fix PPR resume metadata tree mismatch for bot user agents#94630
pnodet wants to merge 1 commit into
vercel:canaryfrom
pnodet:fix/ppr-resume-metadata-bot-mismatch

Conversation

@pnodet

@pnodet pnodet commented Jun 9, 2026

Copy link
Copy Markdown

Fixing a bug

What

Requests from bot user agents to PPR-enabled routes fail to resume the postponed state and fall back to client rendering:

Error: Expected the resume to render <div> in this slot but instead it rendered <__next_metadata_boundary__>. The tree doesn't match so React will fallback to client rendering.

This happens in production (minimal mode) with the default config — no htmlLimitedBots override needed — for any recognized bot (Googlebot as a DOM bot, Twitterbot/Bingbot/etc. as HTML-limited bots). The result is that the dynamic content is deferred to client rendering for exactly the requests that need server-rendered HTML the most (crawlers), while regular browser requests are unaffected.

Why

A resume must produce the exact tree shape that the prerender postponed. Prerenders always render with streaming metadata enabled (serveStreamingMetadata is set to true during build/export), and the metadata component tree has a different shape depending on that flag — the streaming variant wraps the metadata boundary in a <div hidden> (createMetadataComponents).

The route handler however forces the flag to false for any bot user agent on a PPR-enabled route:

// packages/next/src/build/templates/app-page.ts
const serveStreamingMetadata =
  botType && isRoutePPREnabled
    ? false
    : ...

The intended behavior for bots on PPR routes is a single-pass blocking render (shouldWaitOnAllReady), which works when the server controls the whole response. In minimal mode however the platform serves the prerendered shell from its cache and only invokes the server to resume the postponed state — so the bot opt-out is applied to one half of a render whose other half was produced with streaming metadata enabled. When the page is dynamic at the segment level, the metadata slot is part of the postponed hole, and the resume renders <MetadataBoundary> where the postponed state expects the <div>.

The same mismatch is reproducible with a custom htmlLimitedBots config that matches browser user agents (#93401, #93370) — that path is also covered by this fix for resumes.

How

When renderOpts.postponed is present (i.e. the render is a resume), always render with streaming metadata enabled, matching the prerender that produced the postponed state. The user-agent based opt-out still applies to single-pass renders.

The added test builds a cacheComponents app with a dynamic page and dynamic generateMetadata, runs the standalone server in minimal mode, and replays the build-time postponed state with Next-Resume. Before the fix, the Googlebot and Twitterbot cases fail with the tree mismatch above; the browser case passes:

✓ should resume without a metadata tree mismatch for a browser
✕ should resume without a metadata tree mismatch for Googlebot
✕ should resume without a metadata tree mismatch for Twitterbot

After the fix all three pass.

When a route rendered with PPR is resumed, the render must produce the
exact tree shape that the prerender postponed. Prerenders always render
with streaming metadata enabled (`serveStreamingMetadata` is set to
`true` during build/export), but the route handler forces it to
`false` for any bot user agent on a PPR-enabled route. The metadata
component tree has a different shape depending on that flag (the
streaming variant wraps the metadata boundary in a hidden div), so the
resume produced a mismatched tree:

    Error: Expected the resume to render <div> in this slot but instead
    it rendered <__next_metadata_boundary__>. The tree doesn't match so
    React will fallback to client rendering.

React then bailed out of the resume and deferred the dynamic content to
client rendering, which is the worst outcome for exactly the requests
that need server-rendered HTML the most (crawlers).

The intended behavior for bots on PPR routes is a single-pass blocking
render, which works when the server controls the whole response. In
minimal mode however the platform serves the prerendered shell from its
cache and only invokes the server to resume the postponed state, so the
bot opt-out was applied to one half of a render whose other half was
produced with streaming metadata enabled.

Fix: when `renderOpts.postponed` is present (a resume), always render
with streaming metadata enabled to match the prerender. The user-agent
based opt-out still applies to single-pass renders.

The new test reproduces the minimal-mode resume with a Googlebot (DOM
bot) and a Twitterbot (HTML-limited bot) user agent; both failed with
the tree mismatch before this change.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant