fix(metadata): match Next dynamic route semantics#1317
Conversation
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.
commit: |
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.
|
/bigbonk review |
There was a problem hiding this comment.
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:
-
metadata-routes.ts— Extension arrays now consistently list.tsx,.ts,.jsx,.jsfor all dynamic metadata route types. RobotsHostbeforeSitemapordering matches Next.jsresolve-route-data.ts. Both verified. -
metadata-route-response.ts—withMetadataRouteCacheHeadercorrectly preserves user-setCache-Controland only adds the default when absent. The newResponseconstruction passesbody(not cloned), which is fine since the original response is being replaced, not reused. -
metadata.tsx— The URL resolution refactor cleanly separates four URL categories: generic metadata URLs (resolveMetadataUrl), canonical URLs (resolveCanonicalUrlwith relative resolution against pathname), manifest URLs (rawstringifyUrl, no base resolution), and social image URLs (resolveSocialImageUrlwith fallback base). ThegetSocialImageMetadataBaseFallbacklogic matches Next.jsresolve-url.tssemantics. -
file-based-metadata.ts— The non-enumerablemetadataRoutemarker is a clean way to signal internal metadata route images without polluting serialized output. -
app-page-boundary-render.ts/app-page-route-wiring.tsx/app-page-element-builder.ts— Pathname threading toMetadataHeadis consistent across normal renders, error boundaries, and HTTP access fallbacks. -
Tests — Good coverage: Next.js test references included,
expectRobotsToMatchNextcross-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"; |
There was a problem hiding this comment.
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.
| 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 { |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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.
|
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:
The URL resolution refactor in I left 4 non-blocking inline comments: a documentation suggestion for the fixture's surprising |
| 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; | ||
| } |
There was a problem hiding this comment.
Surprised to see these variables making an appearance!
Overview
metadata-dynamic-routescases.server/*; rendered head URL semantics stay in the metadata shim.metadata-routes.ts,metadata-route-response.ts,metadata.tsx,file-based-metadata.ts, app page head wiring.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
Responseobjects or file-based metadata contributes head entries..tsx,.ts,.jsx, and.jsfor dynamic metadata routes, includingsitemap.tsx.Cache-Controlto handler-returned metadataResponses without overriding user-set headers.HostbeforeSitemap.What Changed
app/foo/sitemap.tsx/foo/sitemap.xml404ed.robots.tswithhostandsitemapSitemapappeared beforeHost.HostthenSitemapordering.ResponseCache-Controlunless handler set it.metadataBase: new URL("https://mydomain.com")and canonical./at/https://mydomain.com/.https://mydomain.com.metadataBasemetadataBaseMaintainer Review Path
Suggested review order
packages/vinext/src/server/metadata-routes.tspackages/vinext/src/server/metadata-route-response.tspackages/vinext/src/shims/metadata.tsxpackages/vinext/src/server/file-based-metadata.tspackages/vinext/src/server/app-page-*.ts(x)Validation
Commands run
vp checkvp 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.tsvp test run tests/metadata-route-response.test.tsvp test run tests/metadata-routes.test.ts tests/features.test.ts tests/app-page-route-wiring.test.ts tests/app-page-element-builder.test.tsRisk / Compatibility
Relevant risks
.jsxand.tsxbroadens dynamic metadata route support to match Next's default page extensions.Cache-Controlremains authoritative because the default is only added when absent.metadataBase; manifest and file-based social image routes now follow their separate Next semantics.Non-Goals
Out of scope
pageExtensionsconfiguration for metadata route discovery.References
is-metadata-route.tstsxandjsx.create-app-route-code.tsnext-metadata-route-loader.tsresolve-route-data.tsresolve-url.tsresolve-opengraph.ts