Skip to content

fix(metadata): match Next dynamic route semantics#1317

Merged
james-elicx merged 2 commits into
cloudflare:mainfrom
NathanDrake2406:nathan/metadata-dynamic-routes
May 19, 2026
Merged

fix(metadata): match Next dynamic route semantics#1317
james-elicx merged 2 commits into
cloudflare:mainfrom
NathanDrake2406:nathan/metadata-dynamic-routes

Conversation

@NathanDrake2406

Copy link
Copy Markdown
Contributor

Overview

Field Details
Goal Bring App Router dynamic metadata routes closer to Next.js behavior for the failing metadata-dynamic-routes cases.
Core change Align metadata route discovery, robots serialization, dynamic route cache headers, and rendered metadata URL resolution.
Main boundary Metadata route scanning and response shaping stay in server/*; rendered head URL semantics stay in the metadata shim.
Primary files metadata-routes.ts, metadata-route-response.ts, metadata.tsx, file-based-metadata.ts, app page head wiring.
Expected impact Existing metadata routes keep working, with additional Next-compatible TSX/JSX discovery and corrected head URL output.

Why

Metadata routes are framework-owned routes, so vinext has to apply Next's route discovery, response headers, and rendered URL rules even when userland handlers return plain Response objects or file-based metadata contributes head entries.

Area Principle / invariant What this PR changes
Route discovery Dynamic metadata route files use configured page extensions in Next. Accepts .tsx, .ts, .jsx, and .js for dynamic metadata routes, including sitemap.tsx.
Route responses The metadata route loader owns default cache headers. Adds default Cache-Control to handler-returned metadata Responses without overriding user-set headers.
Robots output Serialized route data should match Next's observable text. Emits Host before Sitemap.
Head URLs Canonical, manifest, and social image URLs are not the same class of metadata URL. Canonical root URLs drop the trailing slash, manifest route hrefs remain relative, and file-based social images use Next's fallback metadata base rules.

What Changed

Scenario Before After
app/foo/sitemap.tsx Not discovered, so /foo/sitemap.xml 404ed. Discovered and served as a dynamic sitemap route.
robots.ts with host and sitemap Sitemap appeared before Host. Output matches Next's Host then Sitemap ordering.
Dynamic image metadata route returning Response Missing framework Cache-Control unless handler set it. Gets Next-style metadata route cache headers by default.
metadataBase: new URL("https://mydomain.com") and canonical ./ at / Rendered https://mydomain.com/. Renders https://mydomain.com.
File-based social image routes with no metadataBase Stayed relative. Resolve through localhost or deployment fallback like Next.
Manifest metadata route with metadataBase Became absolute. Stays relative.

Maintainer Review Path

Suggested review order
  1. packages/vinext/src/server/metadata-routes.ts
    • Verify extension parity and robots serialization order.
  2. packages/vinext/src/server/metadata-route-response.ts
    • Check cache header ownership and preservation of handler-set headers.
  3. packages/vinext/src/shims/metadata.tsx
    • Review URL class separation for canonical, generic metadata URLs, manifest, and social images.
  4. packages/vinext/src/server/file-based-metadata.ts
    • Check the non-enumerable internal marker for file-based social image routes.
  5. packages/vinext/src/server/app-page-*.ts(x)
    • Confirm actual request pathname is threaded into metadata head rendering.
  6. Tests
    • Review the ported Next.js regression cases and focused lower-boundary assertions.

Validation

Commands run
  • vp check
  • vp test run tests/metadata-routes.test.ts tests/metadata-route-response.test.ts tests/file-based-metadata.test.ts tests/features.test.ts -t "Host before Sitemap|discovers dynamic sitemap.tsx routes|metadata cache control|root canonical|manifest metadata routes|social image fallback|leaf file image|leaf Twitter file image|dynamic metadata module exports|multiple generateImageMetadata"
  • vp test run tests/app-router.test.ts -t "serves sitemap routes that import but do not render client references|serves /sitemap.xml from dynamic sitemap.ts|serves /robots.txt from dynamic robots.ts|serves /manifest.webmanifest from dynamic manifest.ts|serves dynamic opengraph-image in dynamic segment with params|serves dynamic icon routes generated by generateImageMetadata|injects dynamic metadata image routes into the head|injects multiple generateImageMetadata icon routes into the head"
  • vp test run tests/file-based-metadata.test.ts
  • vp test run tests/metadata-route-response.test.ts
  • vp test run tests/metadata-routes.test.ts tests/features.test.ts tests/app-page-route-wiring.test.ts tests/app-page-element-builder.test.ts

Risk / Compatibility

Relevant risks
  • Public API: no new public metadata field is introduced. The metadata-route social image marker is non-enumerable and read only as an internal runtime signal.
  • Route discovery: accepting .jsx and .tsx broadens dynamic metadata route support to match Next's default page extensions.
  • Cache headers: user-provided Cache-Control remains authoritative because the default is only added when absent.
  • URL rendering: generic metadata URLs still use metadataBase; manifest and file-based social image routes now follow their separate Next semantics.

Non-Goals

Out of scope
  • Full metadata resolver parity beyond the failing dynamic metadata route cases.
  • Custom pageExtensions configuration for metadata route discovery.
  • Reworking the metadata shim into a separate resolver module.

References

Reference Why it matters
Next is-metadata-route.ts Shows metadata route matching uses default/page extensions including tsx and jsx.
Next create-app-route-code.ts Shows metadata route files are wrapped through the metadata route loader.
Next next-metadata-route-loader.ts Owns metadata route response construction and default cache headers.
Next resolve-route-data.ts Defines robots and sitemap serialization behavior.
Next resolve-url.ts Defines metadata URL fallback behavior, including localhost/deployment fallbacks.
Next resolve-opengraph.ts Applies social image metadataBase fallback for relative and static metadata route images.
Next metadata-dynamic-routes test Source of the parity regressions covered by this PR.

Metadata routes diverged from Next.js in several observable cases: robots.txt emitted Sitemap before Host, dynamic metadata Response results could miss framework cache headers, TSX sitemap files were not discovered, and rendered metadata URLs treated canonical, manifest, and file-based social image routes as the same URL class.

The implementation now follows Next's metadata route extension, cache, robots, and URL resolution rules while keeping file-based social image route markers non-enumerable so resolved metadata shape does not leak internal state.
@pkg-pr-new

pkg-pr-new Bot commented May 19, 2026

Copy link
Copy Markdown

Open in StackBlitz

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

commit: ab24a2d

File-based social image routes used the preview deployment URL whenever VERCEL_URL was present outside development. That let deployment URLs override an explicit metadataBase in non-preview production environments.

Gate preview URL fallback on VERCEL_ENV=preview, add coverage for metadataBase precedence and production fallback URLs, and keep static metadata image cache headers stable in development.
@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thorough parity work. The route discovery extension, robots serialization fix, cache header ownership, canonical URL normalization, manifest bypass, and social image fallback logic all look correct and well-tested. A few observations below — none are blocking.

What I checked:

  1. metadata-routes.ts — Extension arrays now consistently list .tsx, .ts, .jsx, .js for all dynamic metadata route types. Robots Host before Sitemap ordering matches Next.js resolve-route-data.ts. Both verified.

  2. metadata-route-response.tswithMetadataRouteCacheHeader correctly preserves user-set Cache-Control and only adds the default when absent. The new Response construction passes body (not cloned), which is fine since the original response is being replaced, not reused.

  3. metadata.tsx — The URL resolution refactor cleanly separates four URL categories: generic metadata URLs (resolveMetadataUrl), canonical URLs (resolveCanonicalUrl with relative resolution against pathname), manifest URLs (raw stringifyUrl, no base resolution), and social image URLs (resolveSocialImageUrl with fallback base). The getSocialImageMetadataBaseFallback logic matches Next.js resolve-url.ts semantics.

  4. file-based-metadata.ts — The non-enumerable metadataRoute marker is a clean way to signal internal metadata route images without polluting serialized output.

  5. app-page-boundary-render.ts / app-page-route-wiring.tsx / app-page-element-builder.ts — Pathname threading to MetadataHead is consistent across normal renders, error boundaries, and HTTP access fallbacks.

  6. Tests — Good coverage: Next.js test references included, expectRobotsToMatchNext cross-validates against the actual Next.js implementation, env var isolation is correct.

Overall: clean separation of concerns, well-referenced to Next.js source, and the test coverage directly targets the parity gaps being fixed.

import React from "react";
import { clientRef } from "./client-component";

export const contentType = "image/png";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit: contentType: "image/png" on a sitemap is surprising at first glance. This matches the upstream Next.js fixture exactly (it's there to test that metadata routes handle client references, not about the content type), but a brief comment would save future readers a double-take.

Suggested change
export const contentType = "image/png";
// Matches upstream Next.js fixture — contentType is irrelevant to this test,
// which validates that metadata routes handle client reference imports.
export const contentType = "image/png";

return origin ? new URL(`https://${origin}`) : null;
}

function getSocialImageMetadataBaseFallback(metadataBase: URL | null | undefined): URL {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Minor observation: getSocialImageMetadataBaseFallback reads process.env variables on every call (every social image URL resolution). In practice this is fine since metadata rendering happens once per request, but if this ever becomes a hot path, the env reads could be hoisted to module level or memoized. Not blocking.

if (
typeof imageUrl === "string" &&
!isAbsoluteOrProtocolRelativeUrl(imageUrl) &&
(!metadataBase || metadataRoute)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Readability nit: the condition (!metadataBase || metadataRoute) means "use fallback base when there's no metadataBase, OR when this is a metadata route image (even if metadataBase is set)". The second case (metadata route overrides user-set metadataBase) is the subtle one — Next.js does this because file-based metadata images need absolute URLs for social sharing even when the user's metadataBase wouldn't make sense for OG crawlers. The logic is correct, but the condition reads as a bit inverted. Consider a comment explaining the "metadataRoute always uses fallback" invariant for future readers.

return CACHE_HEADERS.revalidate;
}

function withMetadataRouteCacheHeader(response: Response, route: MetadataRuntimeRoute): Response {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Note: new Response(response.body, ...) transfers the body stream to the new Response, which means the original response becomes consumed. This is fine here since the original is immediately discarded, but worth noting that this function is not safe to call if the caller also needs to read the original response.

@ask-bonk

ask-bonk Bot commented May 19, 2026

Copy link
Copy Markdown
Contributor

Review submitted. Here's a summary of my findings:

Verdict: Approved

This PR cleanly aligns metadata route discovery, response headers, robots serialization, and rendered URL semantics with Next.js behavior across 6 areas:

  1. Route discovery.tsx/.jsx extensions now accepted for all dynamic metadata routes (sitemap, robots, manifest, icons, etc.)
  2. Robots outputHost emitted before Sitemap, matching Next.js
  3. Cache headerswithMetadataRouteCacheHeader adds default Cache-Control to handler-returned Response objects without overriding user-set headers
  4. Canonical URL — Root canonical "./" at / resolves to https://mydomain.com (no trailing slash)
  5. Manifest — Stays relative, not resolved against metadataBase
  6. Social images — File-based metadata route images use Next.js fallback base resolution (localhost/preview/production URLs)

The URL resolution refactor in metadata.tsx separates four URL categories cleanly. The non-enumerable metadataRoute marker on social image entries is a good pattern. Tests cross-validate against the actual Next.js implementation (expectRobotsToMatchNext).

I left 4 non-blocking inline comments: a documentation suggestion for the fixture's surprising contentType, a note about env var reads in getSocialImageMetadataBaseFallback, a readability suggestion for the metadataRoute fallback condition, and a stream ownership note on withMetadataRouteCacheHeader.

github run

Comment on lines +606 to +614
function getPreviewDeploymentUrl(): URL | null {
const origin = process.env.VERCEL_BRANCH_URL || process.env.VERCEL_URL;
return origin ? new URL(`https://${origin}`) : null;
}

function getProductionDeploymentUrl(): URL | null {
const origin = process.env.VERCEL_PROJECT_PRODUCTION_URL;
return origin ? new URL(`https://${origin}`) : null;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Surprised to see these variables making an appearance!

@james-elicx
james-elicx merged commit 1551f91 into cloudflare:main May 19, 2026
31 checks passed
@NathanDrake2406
NathanDrake2406 deleted the nathan/metadata-dynamic-routes branch May 21, 2026 09:07
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