cacheComponents: dynamic-`generateMetadata` check fires on partial fallback shells that Next itself marked `allowEmptyStaticShell` — no userland fix exists
invalid link
## Link to the code that reproduces this
**No public reproduction repository — the affected app is private.** Being upfront about what is and
isn't verified here, since it affects how much weight to give each section:
- **Verified, from real `next build` runs:** the failure itself, the exact error, the instrumented
guard inputs (`allowEmpty=true`), the fact that patching one condition in the compiled runtime makes
the build pass with *zero* application changes, and that `16.3.1-canary.7` still reproduces.
- **Not verified:** the standalone scaffold under "Minimal reproduction" below. It was derived by
reduction from the failing app, not executed on its own.
Happy to build and publish a runnable minimal repro if that's the blocker for triage — the reduction is
straightforward, it just wasn't needed to diagnose this.
## To Reproduce
A route with a dynamic segment whose `generateStaticParams` returns a single placeholder set, plus a
`generateMetadata` that reads any route param:
```
app/[tenant]/[locale]/page.tsx
```
```tsx
export const generateStaticParams = async () => {
// Real apps: build-time route discovery returned nothing (our API is deliberately
// unreachable during `next build`), so we emit a sentinel.
return [{ tenant: '__placeholder__', locale: 'en' }];
};
export const generateMetadata = async ({ params }) => {
const { tenant, locale } = await params; // ← any param read is enough
return { title: tenant, alternates: { canonical: `/${locale}` } };
};
// The body must produce a FULL prelude — the check requires `prelude === PreludeState.Full`.
// In our app that happens because an ancestor layout resolves the placeholder tenant and calls
// `notFound()`, so the shell renders a complete 404 page while `generateMetadata` is still hanging
// on the unknown `locale`. Any body that renders without suspending on request-time data will do.
export default function Page() {
return <main>static body</main>;
}
```
`next.config.ts`: `{ cacheComponents: true }`.
Run `next build`.
## Current vs. Expected behavior
**Current:** the build fails while prerendering the *partial fallback shells* — the export paths where
some params are concrete and at least one is still a fallback param:
```
> Export encountered errors on 3 paths:
/(routes)/[tenant]/[locale]/(with-header)/(tenant-overview)/page: /__placeholder__/[locale]
/(routes)/[tenant]/[locale]/(without-header)/restaurant/[restaurantSlug]/menu/page: /__placeholder__/[locale]/restaurant/[restaurantSlug]/menu
/(routes)/[tenant]/[locale]/(without-header)/restaurant/[restaurantSlug]/menu/page: /__placeholder__/en/restaurant/[restaurantSlug]/menu
```
with
```
Route "/[tenant]/[locale]": Next.js encountered uncached or runtime data in `generateMetadata()`.
This route's metadata is blocked, but the rest of its content can be prerendered.
Ways to fix this:
- [static] Use a static metadata export instead of `generateMetadata()`
- [cache] Cache the metadata with `"use cache"` in `generateMetadata()` (does not apply to `connection()`)
- [dynamic] Render a marker component that calls `await connection()` inside `<Suspense>` on the page
Learn more: https://nextjs.org/docs/messages/blocking-prerender-metadata-runtime
```
The fully-concrete shells (`/__placeholder__/en`, `/__placeholder__/en/restaurant/__placeholder__/menu`)
and the fully-unknown shell (`/[tenant]/[locale]`) build fine. Only the *partial* fallback shells fail.
**Expected:** these shells build. Next.js has already decided they are allowed to be empty/blocking, and
none of the three suggested fixes is applicable to a fallback shell (details below).
## Why we believe this is a bug, not a mis-configuration
### 1. Next.js explicitly marks these shells as allowed-to-be-empty, then bypasses that decision
`node_modules/next/dist/build/static-paths/app.js` (Phase 2 of the trie walk) sets
`throwOnEmptyStaticShell = false` for exactly these routes:
```js
// A route is ok not to throw on an empty static shell (and thus
// `throwOnEmptyStaticShell` should be `false`) if either of the
// following conditions is met:
// 1. `hasChildren` is true: ...
// 2. `route.fallbackRouteParams.length > minFallbacks`: ...
if (hasChildren || route.fallbackRouteParams && route.fallbackRouteParams.length > minFallbacks) {
route.throwOnEmptyStaticShell = false // Should not throw on empty static shell.
} else {
route.throwOnEmptyStaticShell = true // Should throw on empty static shell.
}
```
which becomes, in `node_modules/next/dist/build/index.js`:
```js
_allowEmptyStaticShell: !route.throwOnEmptyStaticShell,
```
`/__placeholder__/[locale]` has a concrete child (`/__placeholder__/en`), so `hasChildren === true`
→ `throwOnEmptyStaticShell === false` → `_allowEmptyStaticShell === true`.
But `throwIfDisallowedDynamic` in
`node_modules/next/dist/server/app-render/dynamic-rendering.js` checks the metadata condition
*before* consuming that flag — and says so in its own comment:
```js
function throwIfDisallowedDynamic(workStore, prelude, dynamicValidation, serverDynamic, allowEmptyStaticShell) {
throwIfSyncIOUsed(workStore, serverDynamic);
// The dynamic metadata error is a mistake-detection signal. It fires when the
// rest of the shell is otherwise fully static apart from metadata, suggesting
// the dynamic data access in `generateMetadata` was probably unintentional.
// That condition is independent of whether the user or build phase accepted
// an empty shell, so we surface it before any opt-in bypass.
if (prelude === 0 && dynamicValidation.hasAllowedDynamic === false && dynamicValidation.hasDynamicMetadata) {
console.error((0, _blockingroutemessages.createDynamicOrRuntimeMetadataError)(workStore.route).message);
throw new _staticgenerationbailout.StaticGenBailoutError();
}
// Either flag expresses "this shell is allowed to be empty/blocking":
// - `allowEmptyStaticShell` covers `instant = false` (user opt-in)
// and the build-phase fallback-shell case.
// - `hasSuspenseAboveBody` is the structural opt-in inside the user's root
// layout.
// Treat them as synonyms for the purpose of bypassing shell-failure errors.
if (allowEmptyStaticShell || dynamicValidation.hasSuspenseAboveBody) {
return;
}
...
}
```
The second comment states `allowEmptyStaticShell` "covers … **the build-phase fallback-shell case**" —
which is precisely the case that the first check rejects before that `return` is reachable.
We instrumented `throwIfDisallowedDynamic` to log its inputs. For the failing path:
```
[[GUARD]] /[tenant]/[locale] prelude=0 allowEmpty=true hasAllowedDynamic=false hasDynamicMetadata=true hasSuspenseAboveBody=false errs=0
```
`allowEmpty=true` — the bypass is armed, the guard just never gets to it.
### 2. The mistake-detection premise does not hold for a fallback shell
The check assumes the dynamic access in `generateMetadata` "was probably unintentional." In a fallback
shell it is *unavoidable*: the metadata depends on a route param, and in a fallback shell that param is
by definition unknown. `createStaticPrerenderParams` in
`node_modules/next/dist/server/request/params.js` hangs the **entire** params promise if *any* param
is a fallback param:
```js
const fallbackParams = prerenderStore.fallbackRouteParams;
if (hasFallbackRouteParams(underlyingParams, fallbackParams)) {
// This params object has one or more fallback params, so we need
// to consider the awaiting of this params object dynamic.
return makeHangingParams(underlyingParams, workStore, prerenderStore);
}
```
So `await params` inside `generateMetadata` can never resolve for `/__placeholder__/[locale]`, no matter
which properties are destructured, and no matter whether the function is `'use cache'`.
### 3. None of the three suggested fixes can work here
| Suggested fix | Why it can't apply |
|---|---|
| `[static]` static `metadata` export | Title/description/canonical are per-tenant and per-locale; they *are* the route params. |
| `[cache]` `'use cache'` in `generateMetadata` | `'use cache'` doesn't make a hanging params promise resolve — see (2). The docs' own `'use cache'` + `generateStaticParams` example only covers *enumerated* params, not fallback shells. |
| `[dynamic]` `connection()` marker inside `<Suspense>` on the page | The page subtree is never rendered for these shells: an ancestor layout resolves the placeholder tenant and throws `notFound()`, so nothing inside the page — marker included — mounts. Verified empirically. |
Both documented opt-outs are also ineffective, for the same structural reason:
- `export const instant = false` — resolves through `isPageAllowedToBlock()` into
`allowEmptyStaticShell`, i.e. the exact flag this check runs ahead of.
- `experimental.instantInsights.validationLevel: 'manual-warning'` — same path.
### 4. One-line change makes the build pass with zero app changes
Adding the flag to the condition — i.e. honouring the bypass that the surrounding comment already claims
covers this case:
```diff
- if (prelude === 0 && dynamicValidation.hasAllowedDynamic === false && dynamicValidation.hasDynamicMetadata) {
+ if (prelude === 0 && !allowEmptyStaticShell && dynamicValidation.hasAllowedDynamic === false && dynamicValidation.hasDynamicMetadata) {
```
We applied this to the compiled `app-page*.runtime.*.js` bundles in `node_modules` and re-ran
`next build` **with no application changes at all**: the build completes successfully and all shells are
emitted as `◐ (Partial Prerender)`, including the three that previously failed.
(We are not proposing this exact diff as the fix — moving the check after the bypass, or scoping it to
`fallbackRouteParams.size === 0`, may be more appropriate. The point is that the check is the only
blocker.)
## Minimal reproduction
*Derived from the failure in our app by reduction — we reproduced and diagnosed against our own
codebase and have not executed this standalone scaffold. Everything above is from real builds.*
1. `npx create-next-app@16.3.0`, set `cacheComponents: true`.
2. Add `app/[a]/[b]/page.tsx` with:
- `generateStaticParams` returning a single set (`[{ a: 'x', b: 'y' }]`),
- `generateMetadata` that does `const { a } = await params` and returns a title derived from it,
- a page body that renders without suspending on anything request-time (so the prelude is Full).
3. `next build`.
Expected failing export path: `/x/[b]` — the partial fallback shell.
## Versions
| | |
|---|---|
| Next.js | **16.3.0** — fails. **16.3.1-canary.7** (2026-08-07) — still fails, identical error. **16.2.12** — builds fine. |
| React | 19.2.3 |
| next-intl | 4.13.1 |
| Node | 24.14.0 |
| Bundler | Turbopack (default) |
| OS | macOS (darwin 25.5.0). Nothing environment-specific is involved: our build-time API client rejects every request during `next build` by design, so `generateStaticParams` deterministically falls back to the placeholder set on every machine and in CI. |
| Config | `cacheComponents: true`, `experimental.serverActions.allowedOrigins` |
## Regression
Yes — 16.2.12 → 16.3.0. Likely related to the 16.3.0 partial-fallback-shell work
("Restore partial fallback shell upgrade coverage", "partial fallbacks: adapter support for
intermediate shells") combined with the new Instant Navigations validation.
## Additional context
The only app-side workaround we found is to render a `<Suspense>`-wrapped `await connection()` marker
in a layout **above** the ancestor that short-circuits, so that it actually mounts for the placeholder
shell. That makes the build pass, but it sets `hasAllowedDynamic = true` for every route in the app,
permanently disabling this mistake-detection everywhere — which is not an acceptable trade for a
framework false positive.
关闭于 16 天前 1 条评论