Skip to content

fix: serve metadata routes in dynamic segments#500

Merged
james-elicx merged 2 commits intocloudflare:mainfrom
NathanDrake2406:fix/metadata-routes-dynamic-segments
Mar 12, 2026
Merged

fix: serve metadata routes in dynamic segments#500
james-elicx merged 2 commits intocloudflare:mainfrom
NathanDrake2406:fix/metadata-routes-dynamic-segments

Conversation

@NathanDrake2406
Copy link
Copy Markdown
Contributor

Summary

  • Metadata routes (opengraph-image, icon, twitter-image, etc.) inside dynamic segments returned 404
  • e.g., app/blog/[slug]/opengraph-image.tsx → requesting /blog/hello-world/opengraph-image → 404
  • Root cause: runtime matching used strict equality (cleanPathname === metaRoute.servedUrl) but servedUrl contains literal bracket notation (/blog/[slug]/opengraph-image)
  • Additionally, params were never passed to the metadata function — Next.js passes { params } so the function can generate per-page images

Fix

  • At code-gen time, detect dynamic segments in servedUrl and generate patternParts (reusing the existing matchPattern() function already used for intercept routes)
  • At runtime, use matchPattern() for metadata routes with patternParts to extract params
  • Pass extracted params to the metadata function via makeThenableParams(), matching Next.js behavior

Test plan

  • Scanner test: scanMetadataFiles discovers opengraph-image in dynamic segments
  • Integration test: /blog/hello-world/opengraph-image returns 200 with correct params
  • Integration test: different param values produce different responses
  • All existing metadata route tests still pass (20/20)
  • Entry template snapshots updated
  • CI: full Vitest + Playwright suite

…, etc.)

Metadata routes like opengraph-image.tsx inside dynamic segments
(e.g., app/blog/[slug]/opengraph-image.tsx) returned 404 because the
runtime matching used strict equality against the servedUrl which
contains literal bracket notation ([slug]).

Two fixes:
- Generate patternParts for metadata routes with dynamic segments so
  the runtime uses matchPattern() instead of strict equality
- Pass extracted params to the metadata function via makeThenableParams()
  matching Next.js behavior where dynamic metadata routes receive { params }
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Mar 12, 2026

Open in StackBlitz

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

commit: 7a04011

@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

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

Good fix — the root cause analysis is correct and the approach of reusing matchPattern() with code-gen'd patternParts is clean and consistent with how intercept routes already work. The tests cover the right cases.

A few items to consider:

  1. Indentation issue in generated code — The try/catch block for static metadata files (lines 1730-1738) has mis-indented closing braces inherited from the original code. The catch is indented to look like it's inside the Response constructor. Since this PR restructures this block (removing a nesting level), it's a good opportunity to fix the indentation.

  2. patternParts on static metadata routes — Static metadata files are physical assets served from embedded base64. In practice they won't appear inside dynamic segments (a literal favicon.ico inside app/blog/[slug]/ wouldn't be meaningful). Generating patternParts for them is harmless but unused. Consider guarding with if (mr.isDynamic && patternParts) or adding a comment.

  3. Segment-to-pattern conversion duplication — The [param]:param conversion logic at codegen time is duplicated from the route scanner. A // Keep in sync with routing/app-router.ts comment would help future maintainers.

servedUrl: ${JSON.stringify(mr.servedUrl)},
contentType: ${JSON.stringify(mr.contentType)},
module: ${getImportVar(mr.filePath)},
module: ${getImportVar(mr.filePath)},${patternParts ? `\n patternParts: ${patternParts},` : ""}
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.

The patternParts property is only useful for dynamic metadata routes (where module.default is called). For static metadata routes, the file is served from embedded base64 and doesn't have URL params to extract. Consider only emitting patternParts for dynamic routes, or add a comment explaining if future-proofing is intentional:

Suggested change
module: ${getImportVar(mr.filePath)},${patternParts ? `\n patternParts: ${patternParts},` : ""}
module: ${getImportVar(mr.filePath)},${patternParts ? `\n patternParts: ${patternParts},` : ""}
}`;
}
// Static: read file and embed as base64

(The suggestion keeps the current behavior — this is a nit, not a blocker.)

Comment on lines +223 to +239
// Convert dynamic segments in servedUrl to matchPattern format:
// [param] → :param
// [...param] → :param+
// [[...param]] → :param*
const patternParts = mr.servedUrl.includes("[")
? JSON.stringify(
mr.servedUrl
.split("/")
.filter(Boolean)
.map((seg) => {
if (seg.startsWith("[[...") && seg.endsWith("]]"))
return ":" + seg.slice(5, -2) + "*";
if (seg.startsWith("[...") && seg.endsWith("]")) return ":" + seg.slice(4, -1) + "+";
if (seg.startsWith("[") && seg.endsWith("]")) return ":" + seg.slice(1, -1);
return seg;
}),
)
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.

This [param]:param conversion duplicates the pattern format used in routing/app-router.ts. If the format ever changes, both would need to stay in sync. Consider adding a comment:

// Keep in sync with routing/app-router.ts patternParts generation

// Dynamic metadata route — call the default export and serialize
const metaFn = metaRoute.module.default;
if (typeof metaFn === "function") {
const result = await metaFn({ params: makeThenableParams(_metaParams || {}) });
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.

Nice — passing { params: makeThenableParams(_metaParams || {}) } matches the Next.js convention where metadata functions receive { params }. The || {} fallback handles the non-dynamic-segment case cleanly (static paths matched by equality where _metaParams stays null).

// to avoid Satori/Resvg dependencies in the test environment.
export default async function OGImage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
return new Response(`og:${slug}`, {
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.

Smart approach — returning a plain Response instead of ImageResponse/satori avoids heavy native deps in tests while still exercising the full metadata route matching + param extraction pipeline.

Comment thread tests/app-router.test.ts
it("serves dynamic opengraph-image in dynamic segment with params", async () => {
const res = await fetch(`${baseUrl}/blog/hello-world/opengraph-image`);
expect(res.status).toBe(200);
expect(res.headers.get("content-type")).toContain("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.

Consider also asserting the content-type is exactly image/png (not just contains) since the fixture explicitly sets it. Also, you might want a test for a metadata route in a non-dynamic segment to ensure the patternParts === null / strict-equality path still works — though the existing 20 passing tests likely cover that already.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented Mar 12, 2026

Review posted successfully. Here's a summary of the review:

Overall assessment: Good fix. The root cause (strict equality matching against bracket-notation servedUrl for dynamic segments) is correctly identified, and the solution reuses the existing matchPattern() infrastructure cleanly.

Key feedback points:

  1. Indentation issue — The try/catch block for static metadata files (lines 1730-1738) has mis-indented closing braces inherited from the original code. Since this PR restructures this block by removing a nesting level, it's a good opportunity to fix it.

  2. patternParts on static metadata routes — Generating patternParts for static (base64-embedded) metadata files is harmless but effectively dead code, since physical assets like favicon.ico wouldn't meaningfully exist inside dynamic segments.

  3. Duplication of segment-to-pattern conversion — The [param]:param logic duplicates what the route scanner already does. A sync comment would help maintainers.

  4. Tests are well-structured — Scanner test + two integration tests with different param values provide good coverage. The fixture cleverly avoids satori/resvg dependencies by returning a plain Response.

github run

…tation

- Only generate patternParts for dynamic metadata routes (static files
  don't use params)
- Add "keep in sync" comment for segment-to-pattern conversion
- Fix mis-indented try/catch braces in generated static metadata code
@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

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

Good fix. The root cause (strict equality against bracket-notation servedUrl for dynamic segments) is correctly identified, and reusing the existing matchPattern() infrastructure is the right call.

The code-gen/runtime split is clean: patternParts is computed at build time and emitted into the metadata route table, then at runtime the same matchPattern() used for intercept routes handles the matching. Params flow through makeThenableParams() just like regular routes, matching Next.js 15+ behavior.

Tests are well-structured — scanner test confirms discovery, two integration tests with different param values confirm runtime matching and param extraction. The fixture cleverly avoids satori/resvg deps.

A few minor items below, none blocking.

// [...param] → :param+
// [[...param]] → :param*
const patternParts =
mr.isDynamic && mr.servedUrl.includes("[")
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: The mr.isDynamic guard here is good — it prevents generating patternParts for static metadata files (which can't meaningfully exist in dynamic segments). But the mr.servedUrl.includes("[") check is redundant when isDynamic is true, since isDynamic is set based on file extension (.ts/.tsx), not URL shape.

Consider: a dynamic metadata route at the root (e.g., app/opengraph-image.tsx) has isDynamic: true but no brackets in servedUrl. In that case patternParts correctly stays null and the strict-equality path is used. So the includes("[") check is doing useful work — it distinguishes "dynamic file in static segment" from "dynamic file in dynamic segment". This is subtle enough to warrant a one-line comment explaining why both conditions are needed.

Comment on lines +228 to +243
const patternParts =
mr.isDynamic && mr.servedUrl.includes("[")
? JSON.stringify(
mr.servedUrl
.split("/")
.filter(Boolean)
.map((seg) => {
if (seg.startsWith("[[...") && seg.endsWith("]]"))
return ":" + seg.slice(5, -2) + "*";
if (seg.startsWith("[...") && seg.endsWith("]"))
return ":" + seg.slice(4, -1) + "+";
if (seg.startsWith("[") && seg.endsWith("]")) return ":" + seg.slice(1, -1);
return seg;
}),
)
: null;
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.

The segment-to-pattern conversion duplicates convertSegmentsToRouteParts() in routing/app-router.ts. I see the // Keep in sync comment (good), but there's a subtle behavioral difference: convertSegmentsToRouteParts uses regexes to extract param names (e.g., /^\[\.\.\.(\w+)\]$/) and also handles invisible segments (route groups, parallel slots) via isInvisibleSegment(). This codegen version uses startsWith/endsWith string checks and doesn't handle invisible segments.

For metadata routes this is fine today — servedUrl is a cleaned URL path that won't contain route groups or @ slots. But if servedUrl format ever changes, this could silently diverge. Consider extracting a shared segmentToPatternPart(seg: string): string utility that both call sites use, or at minimum documenting that this version intentionally omits invisible-segment handling because servedUrl is already cleaned.

// strict equality for static paths.
var _metaParams = null;
if (metaRoute.patternParts) {
var _metaUrlParts = cleanPathname.split("/").filter(Boolean);
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 perf nit: cleanPathname.split("/").filter(Boolean) is recomputed on every metadata route iteration that has patternParts. Since cleanPathname doesn't change within the loop, this could be hoisted above the for loop (or computed once lazily). Not a real issue for typical metadata route counts, but worth noting for consistency with other loop-hoisted variables in this function.

Comment thread tests/app-router.test.ts
it("serves dynamic opengraph-image in dynamic segment with params", async () => {
const res = await fetch(`${baseUrl}/blog/hello-world/opengraph-image`);
expect(res.status).toBe(200);
expect(res.headers.get("content-type")).toContain("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.

Consider tightening the content-type assertion to an exact match rather than .toContain("image/png"), since the fixture explicitly sets "Content-Type": "image/png". Using toBe("image/png") would catch if something downstream accidentally appends a charset or other parameter.

(Minor — toContain is fine for a first pass since real ImageResponse objects may include additional parameters.)

@@ -0,0 +1,8 @@
// Dynamic OG image in a dynamic segment — returns a plain Response
// to avoid Satori/Resvg dependencies in the test environment.
export default async function OGImage({ params }: { params: Promise<{ slug: string }> }) {
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.

Nice — using Promise<{ slug: string }> for the params type correctly models the Next.js 15+ async params convention, and the await params on line 4 exercises the makeThenableParams() thenable path.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented Mar 12, 2026

Review posted. Here's a summary of my review:

Verdict: Approved -- this is a correct fix with good test coverage.

What the PR does right:

  • Correctly identifies the root cause: strict equality matching (cleanPathname === metaRoute.servedUrl) fails when servedUrl contains literal brackets like /blog/[slug]/opengraph-image
  • Reuses the existing matchPattern() infrastructure (same function used for intercept routes) rather than inventing new matching logic
  • Passes extracted params through makeThenableParams(), matching Next.js 15+ async params behavior
  • Clean code-gen/runtime split: patternParts is computed at build time, pattern matching happens at runtime
  • Good test coverage: scanner test + two integration tests with different param values

Minor feedback (none blocking):

  1. The dual condition mr.isDynamic && mr.servedUrl.includes("[") on line 229 is doing subtle work (distinguishing "dynamic file in static segment" vs "dynamic file in dynamic segment") -- deserves a clarifying comment
  2. The segment-to-pattern conversion duplicates convertSegmentsToRouteParts() from routing/app-router.ts with slightly different behavior (no invisible segment handling). Consider extracting a shared utility or documenting why the difference is intentional
  3. cleanPathname.split("/").filter(Boolean) at line 1704 is recomputed on every metadata route iteration -- could be hoisted above the loop
  4. Content-type assertion could use toBe("image/png") instead of toContain for a tighter check

github run

@james-elicx james-elicx merged commit 7e58941 into cloudflare:main Mar 12, 2026
21 checks passed
@NathanDrake2406 NathanDrake2406 deleted the fix/metadata-routes-dynamic-segments branch March 18, 2026 09:51
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