Skip to content

fix(app-router): stream generated metadata for non-html bots#1585

Open
NathanDrake2406 wants to merge 5 commits into
cloudflare:mainfrom
NathanDrake2406:nathan/metadata-streaming-parity
Open

fix(app-router): stream generated metadata for non-html bots#1585
NathanDrake2406 wants to merge 5 commits into
cloudflare:mainfrom
NathanDrake2406:nathan/metadata-streaming-parity

Conversation

@NathanDrake2406
Copy link
Copy Markdown
Contributor

@NathanDrake2406 NathanDrake2406 commented May 24, 2026

Upstream deploy-suite status

Upstream file Tests Result in this PR
test/e2e/app-dir/metadata-streaming/metadata-streaming-customized-rule.test.ts 2 2/2 passed with run-nextjs-deploy-suite.sh
test/e2e/app-dir/metadata-streaming/metadata-streaming.test.ts 19 Broad file still contains out-of-scope navigation/status failures. Initial reproduction was 9/19 passed, 10 failed; final run reached later navigation/not-found cases and was interrupted with SIGTERM after confirming this PR's custom bot file was green.

Overview

Item Detail
Goal Match Next.js metadata placement for generated App Router metadata on normal versus html-limited bot requests.
Core change Track generateMetadata participation during head resolution, then choose head or hidden body placement from the request user agent.
Primary files app-page-head.ts, app-page-element-builder.ts, app-page-route-wiring.tsx, metadata.tsx, streaming-metadata.ts
Expected impact Normal browser-like requests get generated metadata in the body outlet; html-limited bots keep blocking metadata in the head.

Why

Generated metadata is not always a head-blocking contract in Next.js. For streaming-capable requests, Next renders generated metadata through a hidden body outlet so the initial document can stream without waiting on metadata. For html-limited bots, Next blocks and emits metadata in the head because those crawlers require head-visible tags.

Area Principle / invariant What this PR changes
App Router head resolution The route should know whether metadata came from generateMetadata. Adds hasDynamicMetadata to the resolved head result.
Request handling Bot classification decides streaming metadata, not metadata resolution itself. Threads htmlLimitedBots through config and generated RSC entry into page element construction.
Rendering React hoists <title> and <meta> unless body metadata is emitted as inert HTML. Adds a small metadata HTML serializer for the hidden body outlet.

What changed

Scenario Before After
Route with generateMetadata, normal UA Full metadata rendered in the route head. Charset and viewport stay in head; generated metadata is emitted in a hidden body outlet.
Route with generateMetadata, default html-limited bot Default bot matching existed only implicitly in the initial fix. Twitterbot coverage pins head-blocking metadata for the default bot list.
Route with generateMetadata, configured html-limited bot Config was not threaded into App Router metadata placement. Request matching htmlLimitedBots keeps generated metadata in the head.
Falsy serialized htmlLimitedBots Empty string created an empty regex and treated every UA as blocking. Falsy config falls back to the default bot list, matching Next.js.
Static metadata only Rendered in the head. Unchanged.
Maintainer review path
  1. packages/vinext/src/server/app-page-head.ts: hasDynamicMetadata is derived from layout, page, and active parallel route modules.
  2. packages/vinext/src/server/app-page-element-builder.ts: request UA plus htmlLimitedBots decides body versus head placement.
  3. packages/vinext/src/server/app-page-route-wiring.tsx and packages/vinext/src/shims/metadata.tsx: body metadata is emitted without letting React hoist it back into the head.
  4. packages/vinext/src/config/next-config.ts, packages/vinext/src/entries/app-rsc-entry.ts, packages/vinext/src/index.ts: config threading only.
Validation
  • vp test run tests/app-page-route-wiring.test.ts -t "falls back to the default html-limited bot list": failed before the htmlLimitedBots || DEFAULT fix, then passed.
  • vp test run tests/streaming-metadata.test.ts tests/next-config.test.ts -t "streaming metadata bot matching|resolveNextConfig htmlLimitedBots": 6/6 selected tests passed.
  • vp test run tests/app-page-head.test.ts tests/app-page-route-wiring.test.ts tests/features.test.ts tests/entry-templates.test.ts tests/next-config.test.ts tests/streaming-metadata.test.ts: 496/496 passed.
  • vp check: formatting, lint, and typecheck passed with no warnings.
  • vp env exec --node 24 ./scripts/run-nextjs-deploy-suite.sh /Users/nathan/Projects/vinext/.refs/nextjs-v16.2.6 --retries 0 -c 2 --debug test/e2e/app-dir/metadata-streaming/metadata-streaming.test.ts test/e2e/app-dir/metadata-streaming/metadata-streaming-customized-rule.test.ts: customized-rule file passed 2/2; broader metadata-streaming file is intentionally not claimed green in this PR.
Non-goals
  • Client navigation metadata refresh behavior in the full upstream metadata-streaming.test.ts file.
  • notFound() and redirect() status or boundary behavior from metadata navigation cases.
  • Static prerender classification for all metadata streaming variants.

References

Reference Why it matters
Next.js shouldServeStreamingMetadata Defines html-limited bot blocking semantics and falsy-config fallback.
Next.js config fallback for htmlLimitedBots Shows falsy htmlLimitedBots normalizing to the default bot regex.
Next.js metadata wrapper Shows streaming metadata moving through a hidden body wrapper.
Next.js app-page template UA decision Shows generated app entry deciding serveStreamingMetadata from the request UA and config.
Next.js customized-rule test Source for the custom htmlLimitedBots regression.

Dynamic App Router metadata was always folded into the route head. That diverged from Next.js for normal browser-like requests, where generated metadata is sent through a body outlet while html-limited bots still receive blocking head metadata.

Track whether the matched route uses generateMetadata, thread next.config htmlLimitedBots into the generated RSC entry, and choose head versus body placement at page element construction.
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 24, 2026

Open in StackBlitz

npm i https://pkg.pr.new/vinext@1585

commit: 49b6e5a

Generated metadata must keep the default html-limited bot list when serialized config contains a falsy regex source. Empty config strings previously produced an empty regex, which treated every user agent as blocking and moved generated metadata back into the head.

Add regression coverage for the default Twitterbot path and the streaming body serializer, then match Next.js by falling back to the default bot regex for any falsy htmlLimitedBots value.
@NathanDrake2406 NathanDrake2406 marked this pull request as ready for review May 24, 2026 08:18
Copilot AI review requested due to automatic review settings May 24, 2026 08:18
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR aligns vinext App Router metadata streaming behavior with Next.js by conditionally emitting generateMetadata-driven metadata via a hidden body outlet for streaming-capable user agents, while keeping metadata head-blocking for html-limited bots (default list + configurable htmlLimitedBots).

Changes:

  • Adds hasDynamicMetadata to App Router head resolution to detect generateMetadata participation (including parallel routes).
  • Threads htmlLimitedBots from next.config through the generated RSC entry into page element construction, and uses UA matching to choose "head" vs "body" metadata placement.
  • Introduces a small metadata-to-HTML serializer and adds unit tests covering default bots, configured bots, and falsy-config fallback behavior.

Reviewed changes

Copilot reviewed 12 out of 12 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
tests/next-config.test.ts Updates resolved config expectations to include htmlLimitedBots.
tests/features.test.ts Adds coverage for renderMetadataToHtml serialization behavior.
tests/app-page-route-wiring.test.ts Adds routing-level assertions for head vs hidden-body metadata placement across UA/config cases.
tests/app-page-head.test.ts Adds coverage for hasDynamicMetadata detection.
packages/vinext/src/shims/metadata.tsx Adds renderMetadataToHtml with HTML escaping + element serialization.
packages/vinext/src/server/streaming-metadata.ts Introduces UA-based shouldServeStreamingMetadata helper with default html-limited bot list.
packages/vinext/src/server/app-page-route-wiring.tsx Adds metadataPlacement support and renders body metadata outlet using inert HTML.
packages/vinext/src/server/app-page-head.ts Computes and returns hasDynamicMetadata from layouts/pages/parallel routes.
packages/vinext/src/server/app-page-element-builder.ts Chooses metadata placement based on hasDynamicMetadata + UA matching + htmlLimitedBots.
packages/vinext/src/index.ts Threads htmlLimitedBots into the App Router config passed to entry generation.
packages/vinext/src/entries/app-rsc-entry.ts Embeds serialized htmlLimitedBots into the generated RSC entry and passes it into page building.
packages/vinext/src/config/next-config.ts Adds htmlLimitedBots to config types and resolves it to a serialized regex source string.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread packages/vinext/src/config/next-config.ts Outdated
Comment thread packages/vinext/src/server/streaming-metadata.ts Outdated
Invalid serialized htmlLimitedBots values could reach request handling and throw while building the bot matcher. That puts a config error on the request path and recompiles the same matcher for repeated requests.

Validate serialized regex sources while resolving next.config and reuse compiled bot matchers in the streaming metadata helper. The focused tests cover invalid config, falsy fallback, and matcher reuse.
Config resolution had several local assertions around experimental options, Turbopack aliases, output mode, and Sass options. Those assertions made the boundary wider than necessary in the code adjacent to htmlLimitedBots handling.

Read optional records, strings, arrays, and body-size inputs through typed narrowing helpers so the resolved config path keeps the same semantics without spreading unchecked asserted values.
Config resolution imported the streaming metadata server module to validate htmlLimitedBots. In RSC-backed App Router integration tests, that cross-layer import can pull server runtime modules through config resolution and surface rsc reference-validation errors.

Move the shared html-limited bot matcher cache to a neutral utils module and import it from config and the server streaming metadata helper. This keeps config validation independent from the server runtime graph while preserving the cached matcher behavior.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants