Skip to content

fix(prerender): static metadata route should be optimized by default - #3004

Open
NriotHrreion wants to merge 11 commits into
cloudflare:mainfrom
NriotHrreion:fix/issue-2950-og-image-not-optimized
Open

fix(prerender): static metadata route should be optimized by default#3004
NriotHrreion wants to merge 11 commits into
cloudflare:mainfrom
NriotHrreion:fix/issue-2950-og-image-not-optimized

Conversation

@NriotHrreion

@NriotHrreion NriotHrreion commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Closes #2950

Overview

Generated metadata images such as opengraph-image.tsx should be statically optimized by default when they can be rendered without request-time dependencies, matching Next.js behavior.

Previously, vinext tied metadata prerender eligibility to the callable "use cache" marker. This conflated function-level React Flight caching with route-level response caching, and made "use cache" appear necessary even for otherwise static ImageResponse routes.

This PR fixes the issue by treating generated metadata routes as prerender candidates by default and determining their cacheability from actual runtime dynamic usage. Routes that use request-time APIs such as headers() or explicitly opt into dynamic rendering remain dynamic and are not persisted as prerendered artifacts.

What changed

  • isUseCacheFunction() is removed, and no longer determining the cacheability with "use cache".
  • In metadata route handler, the route renderer is wrapped into runWithIsolatedDynamicUsage() to detect dynamic operations in the route, such as await headers(). Then the dynamicDetected is used as a factor to determine the cacheability.
const render = async (): Promise<RenderedMetadataRoute> => {
  setCurrentFetchSoftTags(...);

  const { result: captured, dynamicDetected } = await runWithIsolatedDynamicUsage(async () =>
    applyMetadataRouteRevalidate(route);
    // ...render response
    // the original `captureRenderedMetadataRoute()` is splitted, see the notes below...
    return captureRenderedMetadataRoute(response);
  );
  const dynamic = isMetadataRouteDynamic(route, dynamicDetected);

  return finalizeRenderedMetadataRoute(captured, dynamic);
};

isMetadataRouteDynamic() here is for dynamic = "force-dynamic" and revalidate = 0:

function isMetadataRouteDynamic(route: MetadataRuntimeRoute, dynamicDetected: boolean): boolean {
  const module = route.module ?? {};

  // `export const dynamic = "force-dynamic"` forces the route to be dynamic.
  if (Reflect.get(module, "dynamic") === "force-dynamic") return true;

  // `export const revalidate = 0` means "never cache",
  // so treat it the same as force-dynamic.
  return Reflect.get(module, "revalidate") === 0 || dynamicDetected;
}
  • In captureRenderedMetadataRoute(), cache control header will be set if dynamicUsage is true.
function captureRenderedMetadataRoute(
  response: Response,
+ dynamicUsage: boolean,
): RenderedMetadataRoute {
  // ...
+ if (dynamicUsage) {
+   applyCdnResponseHeaders(response.headers, {
+     cacheControl: NEVER_CACHE_CONTROL,
+   });
+ }

  return {
    cacheLife,
    collectedTags,
    response,
+   dynamicUsage,
  };
}

Note: The original captureRenderedMetadataRoute() is splitted into capturer and finalizer (finalizeRenderedMetadataRoute()) because the original route capturer calls _consumeRequestScopedCacheLife() outside the runWithIsolatedDynamicUsage(), which may lead to some expected behaviors. So just also wrap the capturer into the runWithIsolatedDynamicUsage(), letting cache life consuming happens inside it.

  • In getPrerenderableMetadataRoutePaths(), generated image IDs are enumerated into paths with ID and pushed into the paths, just as the generated sitemaps do.

Testing

  • Fixture: og-image-optimization
  • pnpm test tests/metadata-route-response.test.ts

@pkg-pr-new

pkg-pr-new Bot commented Aug 20, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@vinext/cloudflare@3004
npm i https://pkg.pr.new/create-vinext-app@3004
npm i https://pkg.pr.new/@vinext/types@3004
npm i https://pkg.pr.new/vinext@3004

commit: 8783664

@github-actions

github-actions Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared 8783664 against base 7ed5570 using alternating same-runner rounds. Next.js was unchanged and skipped.

0 improved · 0 regressed · 6 within ±1.5%

Scenario Framework Baseline Current Change
Client bundle size (gzip) vinext 141.4 KB 141.4 KB ⚫ +0.0%
Client entry size (gzip) vinext 128.8 KB 128.8 KB ⚫ +0.0%
Dev server cold start vinext 3.33 s 3.30 s ⚫ -0.9%
Production build time vinext 3.74 s 3.74 s ⚫ +0.1%
RSC entry closure size (gzip) vinext 115.6 KB 115.6 KB ⚫ -0.0%
Server bundle size (gzip) vinext 196.9 KB 196.8 KB ⚫ -0.0%

View detailed results and traces

🟢 improvement · 🔴 regression · ⚫ change below 1.5% · paired base/head

@NriotHrreion NriotHrreion changed the title fix(og-image): static metadata images should be optimized by default fix(prerender): static metadata route should be optimized by default Aug 20, 2026
@NriotHrreion
NriotHrreion marked this pull request as ready for review August 20, 2026 07:39

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 546a9abfa0

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/vinext/src/server/metadata-route-response.ts Outdated
Comment thread packages/vinext/src/server/metadata-route-response.ts Outdated
@NriotHrreion

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

@NathanDrake2406

Copy link
Copy Markdown
Contributor

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7facc418cf

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/vinext/src/server/metadata-route-response.ts
Comment thread packages/vinext/src/server/metadata-route-response.ts Outdated
Comment thread packages/vinext/src/server/metadata-route-response.ts Outdated
Comment thread packages/vinext/src/server/metadata-route-response.ts Outdated
!rendered.response.ok ||
!isMetadataResponseCacheable(rendered.response) ||
!isUseCacheFunction(functions.defaultExport) ||
!isRenderedMetadataRouteCacheable(rendered) ||

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Keep private metadata responses out of the shared cache

After removing the "use cache" gate, a regular metadata function that returns Cache-Control: private is now admitted to the outer ISR cache because isMetadataResponseCacheable rejects only no-cache and no-store. A later HIT rebuilds the response with shared ISR cache-control, replacing the original private policy and potentially serving request-specific metadata to other users; reject private responses before prerendering or writing them to ISR.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Beyond the scope of this PR

@NriotHrreion

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 336df7006f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +205 to +210
// `export const dynamic = "force-dynamic"` forces the route to be dynamic.
if (Reflect.get(module, "dynamic") === "force-dynamic") return true;

// `export const revalidate = 0` means "never cache",
// so treat it the same as force-dynamic.
return Reflect.get(module, "revalidate") === 0 || dynamicDetected;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Apply static dynamic configs during metadata rendering

When a metadata module exports dynamic = "force-static" and calls headers() or cookies(), the metadata handler never installs the force-static headers context used by configureAppRouteStaticGenerationContext; the isolated detector therefore reports dynamic usage and the prerender is skipped instead of receiving empty request data and producing a static artifact. The related dynamic = "error" mode is also treated like auto, silently skipping rather than raising the required static-generation error. Configure these two modes around metadata execution as the App Route path does.

AGENTS.md reference: AGENTS.md:L174-L181

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Maybe beyond the scope of this PR

Comment thread packages/vinext/src/server/metadata-route-response.ts
@NriotHrreion

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 878366416c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +205 to +210
// `export const dynamic = "force-dynamic"` forces the route to be dynamic.
if (Reflect.get(module, "dynamic") === "force-dynamic") return true;

// `export const revalidate = 0` means "never cache",
// so treat it the same as force-dynamic.
return Reflect.get(module, "revalidate") === 0 || dynamicDetected;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Bypass cached entries for explicitly dynamic metadata routes

When a deployment changes a previously cached metadata route to dynamic = "force-dynamic" or revalidate = 0, this predicate prevents new writes and prerendering but does not prevent readMatchedPrerenderedMetadataRouteResponse() from returning the existing persistent ISR entry before the route executes. The route can therefore continue serving the old static response—and a stale entry indefinitely—despite explicitly opting out of caching; apply the same dynamic decision before attempting the cache read.

AGENTS.md reference: AGENTS.md:L174-L181

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Beyond the scope of this PR

Comment on lines +854 to +858
const response = route.isDynamic
? await callDynamicMetadataRoute(route, match, options.makeThenableParams, functions)
: serveStaticMetadataRoute(route);
return captureRenderedMetadataRoute(response);
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Observe dynamic usage while consuming streaming responses

When a metadata export returns a lazy ImageResponse or Response(stream) whose producer calls headers(), cookies(), noStore(), or establishes cache lifetime while the body is pulled, this isolated scope ends as soon as the Response object is returned. buildAppRouteCacheValue() consumes the body afterward, so that late usage is recorded only in the parent context and rendered.dynamic remains false, allowing request-dependent bytes to be prerendered or written to shared ISR; keep observation active through body materialization or propagate observations made during consumption.

AGENTS.md reference: AGENTS.md:L194-L198

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Beyond the scope of this PR

@NriotHrreion

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Keep it up!

Reviewed commit: 878366416c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@NriotHrreion

Copy link
Copy Markdown
Contributor Author

@james-elicx Ready for a review!

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.

Generated metadata images are not statically optimized by default

2 participants