Skip to content

Commit 2df79a6

Browse files
committed
[dynamicIO] Disallow only dynamic metadata
When I changed the dynamic validation rules to be based on the existence of a static shell I removed an important protection for apps that have static metadata. Now that metadata is implicitly rendered within a Suspense boundary it is always opted into allowing dynamic. For dynamic and partially static pages this is fine because we are going to be generating a response per request anyways. But if you have a fully static page and then later accidentally make your metadata dynamic your page will deopt to partially static without any warning. This change reintroduces the heuristic where if the only dynamic thing on the page is metadata the build errors. If there is at least one other dynamic thing on the page then dynamic metadata is allowed. A similar change is not necessary for viewport because that is never rendered in a Suspense boundary and the only way to have dynamic viewports is to opt the entire app into dynamic with a Suspense boundary around your root layout
1 parent c93f391 commit 2df79a6

File tree

3 files changed

+206
-52
lines changed

3 files changed

+206
-52
lines changed
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
---
2+
title: Cannot access Request information or uncached data in `generateMetadata()` in an otherwise entirely static route
3+
---
4+
5+
## Why This Error Occurred
6+
7+
When `dynamicIO` is enabled, Next.js requires that `generateMetadata()` not depend on uncached data or Request data unless unless some other part of the page also has similar requirements. The reason for this is that while you normally control your intention for what is allowed to be dynamic by adding or removing Suspense boundaries in your Layout and Page components you are not in control of rendering metadata itself.
8+
9+
The heuristic Next.js uses to understand your intent with `generateMetadata()` is to look at the data requirements of the rest of the route. If other components have dependence on Request data and uncached data then we allow `generateMetadata()` to have similar data requirements. If the rest of your page has no dependence on this type of data we require that `generateMetadata()` also not have this type of data dependence.
10+
11+
## Possible Ways to Fix It
12+
13+
To fix this issue, you must first determine your goal for the affected route.
14+
15+
### Caching External Data
16+
17+
If your metadata does not depend on any request data then it may be possible for you to indicate the data is cacheable which would allow Next.js to include it in the prerender for this route. Consider using `"use cache"` to mark the function producing the external data as cacheable.
18+
19+
Before:
20+
21+
```jsx filename="app/.../page.tsx"
22+
import { cms } from './cms'
23+
24+
export async function generateMetadata() {
25+
// This data lookup is not cached at the moment so
26+
// Next.js will interpret this as needing to be rendered
27+
// on every request.
28+
const { title } = await cms.getPageData('/.../page')
29+
return {
30+
title,
31+
}
32+
}
33+
34+
async function getPageText() {
35+
'use cache'
36+
const { text } = await cms.getPageData('/.../page')
37+
return text
38+
}
39+
40+
export default async function Page() {
41+
// This text is cached so the main content of this route
42+
// is prerenderable.
43+
const text = await getPageText()
44+
return <article>{text}</article>
45+
}
46+
```
47+
48+
After:
49+
50+
```jsx filename="app/.../page.tsx"
51+
import { cms } from './cms'
52+
53+
export async function generateMetadata() {
54+
// By marking this function as cacheable, Next.js
55+
// can now include it in the prerender for this route.
56+
'use cache'
57+
const { title } = await cms.getPageData('/.../page')
58+
return {
59+
title,
60+
}
61+
}
62+
63+
async function getPageText() {
64+
'use cache'
65+
const { text } = await cms.getPageData('/.../page')
66+
return text
67+
}
68+
69+
export default async function Page() {
70+
// This text is cached so the main content of this route
71+
// is prerenderable.
72+
const text = await getPageText()
73+
return <article>{text}</article>
74+
}
75+
```
76+
77+
### If you must access Request Data or your external data is uncacheable
78+
79+
If your metadata requires request specific data or depends on external data which is not cacheable then Next.js will need to render this page dynamically on every request. However if you got this error the rest of your page is able to be completely static. This is generally pretty rare but if this is your actual constraint you can indicate to Next.js that the page should be allowed to be dynamic by rendering any other component that is dynamic. Since your route doesn't have any genuine dynamic requirements you might render an artificially dynamic component using `await connection()`. This is like telling Next.js that this component is never prerenderable and must be rendered on every user request.
80+
81+
Before:
82+
83+
```jsx filename="app/.../page.tsx"
84+
import { cookies } from 'next/headers'
85+
86+
export async function generateViewport() {
87+
const cookieJar = await cookies()
88+
return {
89+
themeColor: cookieJar.get('theme-color'),
90+
}
91+
}
92+
93+
export default function Page() {
94+
return <main>This page is entirely static</main>
95+
}
96+
```
97+
98+
After:
99+
100+
```jsx filename="app/.../page.tsx"
101+
import { Suspense } from 'react'
102+
import { cookies } from 'next/headers'
103+
import { connection } from 'next/server'
104+
105+
export async function generateViewport() {
106+
const cookieJar = await cookies()
107+
return {
108+
themeColor: cookieJar.get('theme-color'),
109+
}
110+
}
111+
112+
async function DynamicMarker() {
113+
// This component renders nothing but it will always
114+
// be dynamic because it waits for an actual connection
115+
const NullDynamic = async () => { await connection(): return null }
116+
return <Suspense><NullDynamic /></Suspense>
117+
}
118+
119+
export default function Page() {
120+
return (
121+
<>
122+
<main>This page is entirely static</main>
123+
<DynamicMarker />
124+
</>
125+
)
126+
}
127+
```
128+
129+
Note: The reason to structure this DynamicMarker as a self-contained Suspense boundary is to avoid blocking the actual content of the page from being prerendered. When Partial Prerendering is enabled alongside dynamicIO the static shell will still contain all of the prerenderable content still and only the metadata will stream in dynamically.
130+
131+
## Useful Links
132+
133+
- [`generateMetadata()`](docs/app/api-reference/functions/generate-metadata)
134+
- [`connection()`](docs/app/api-reference/functions/connection)
135+
- [`cookies()`](docs/app/api-reference/functions/cookies)
136+
- [`"use cache"`](/docs/app/api-reference/directives/use-cache)

packages/next/src/server/app-render/app-render.tsx

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ import {
132132
createDynamicValidationState,
133133
getFirstDynamicReason,
134134
trackAllowedDynamicAccess,
135-
throwIfDisallowedEmptyStaticShell,
135+
throwIfDisallowedDynamic,
136136
consumeDynamicAccess,
137137
type DynamicAccess,
138138
} from './dynamic-rendering'
@@ -2553,14 +2553,13 @@ async function spawnDynamicValidationInDev(
25532553
// If we've disabled throwing on empty static shell, then we don't need to
25542554
// track any dynamic access that occurs above the suspense boundary because
25552555
// we'll do so in the route shell.
2556-
if (preludeIsEmpty && !ctx.renderOpts.doNotThrowOnEmptyStaticShell) {
2557-
throwIfDisallowedEmptyStaticShell(
2558-
route,
2559-
dynamicValidation,
2560-
serverDynamicTracking,
2561-
clientDynamicTracking
2562-
)
2563-
}
2556+
throwIfDisallowedDynamic(
2557+
route,
2558+
preludeIsEmpty,
2559+
dynamicValidation,
2560+
serverDynamicTracking,
2561+
clientDynamicTracking
2562+
)
25642563
} catch {}
25652564
return null
25662565
}
@@ -3091,9 +3090,10 @@ async function prerenderToStream(
30913090
// If we've disabled throwing on empty static shell, then we don't need to
30923091
// track any dynamic access that occurs above the suspense boundary because
30933092
// we'll do so in the route shell.
3094-
if (preludeIsEmpty && !ctx.renderOpts.doNotThrowOnEmptyStaticShell) {
3095-
throwIfDisallowedEmptyStaticShell(
3093+
if (!ctx.renderOpts.doNotThrowOnEmptyStaticShell) {
3094+
throwIfDisallowedDynamic(
30963095
workStore.route,
3096+
preludeIsEmpty,
30973097
dynamicValidation,
30983098
serverDynamicTracking,
30993099
clientDynamicTracking
@@ -3577,10 +3577,11 @@ async function prerenderToStream(
35773577
// If we've disabled throwing on empty static shell, then we don't need to
35783578
// track any dynamic access that occurs above the suspense boundary because
35793579
// we'll do so in the route shell.
3580-
if (preludeIsEmpty && !ctx.renderOpts.doNotThrowOnEmptyStaticShell) {
3580+
if (!ctx.renderOpts.doNotThrowOnEmptyStaticShell) {
35813581
// We don't have a shell because the root errored when we aborted.
3582-
throwIfDisallowedEmptyStaticShell(
3582+
throwIfDisallowedDynamic(
35833583
workStore.route,
3584+
preludeIsEmpty,
35843585
dynamicValidation,
35853586
serverDynamicTracking,
35863587
clientDynamicTracking

packages/next/src/server/app-render/dynamic-rendering.ts

Lines changed: 56 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ export type DynamicValidationState = {
8080
hasSuspenseAboveBody: boolean
8181
hasDynamicMetadata: boolean
8282
hasDynamicViewport: boolean
83+
hasAllowedDynamic: boolean
8384
dynamicErrors: Array<Error>
8485
}
8586

@@ -99,6 +100,7 @@ export function createDynamicValidationState(): DynamicValidationState {
99100
hasSuspenseAboveBody: false,
100101
hasDynamicMetadata: false,
101102
hasDynamicViewport: false,
103+
hasAllowedDynamic: false,
102104
dynamicErrors: [],
103105
}
104106
}
@@ -623,11 +625,13 @@ export function trackAllowedDynamicAccess(
623625
} else if (hasSuspenseAfterBodyOrHtmlRegex.test(componentStack)) {
624626
// This prerender has a Suspense boundary above the body which
625627
// effectively opts the page into allowing 100% dynamic rendering
628+
dynamicValidation.hasAllowedDynamic = true
626629
dynamicValidation.hasSuspenseAboveBody = true
627630
return
628631
} else if (hasSuspenseRegex.test(componentStack)) {
629632
// this error had a Suspense boundary above it so we don't need to report it as a source
630633
// of disallowed
634+
dynamicValidation.hasAllowedDynamic = true
631635
return
632636
} else {
633637
const message = `Route "${route}": A component accessed data, headers, params, searchParams, or a short-lived cache without a Suspense boundary nor a "use cache" above it. We don't have the exact line number added to error messages yet but you can see which component in the stack below. See more info: https://nextjs.org/docs/messages/next-prerender-missing-suspense`
@@ -646,54 +650,67 @@ function createErrorWithComponentStack(
646650
return error
647651
}
648652

649-
export function throwIfDisallowedEmptyStaticShell(
653+
export function throwIfDisallowedDynamic(
650654
route: string,
655+
hasEmptyShell: boolean,
651656
dynamicValidation: DynamicValidationState,
652657
serverDynamic: DynamicTrackingState,
653658
clientDynamic: DynamicTrackingState
654659
): void {
655-
if (dynamicValidation.hasSuspenseAboveBody) {
656-
// This route has opted into allowing fully dynamic rendering
657-
// by including a Suspense boundary above the body. In this case
658-
// a lack of a shell is not considered disallowed so we simply return
659-
return
660-
}
661-
662-
if (serverDynamic.syncDynamicErrorWithStack) {
663-
// There is no shell and the server did something sync dynamic likely
664-
// leading to an early termination of the prerender before the shell
665-
// could be completed.
666-
console.error(serverDynamic.syncDynamicErrorWithStack)
667-
// We terminate the build/validating render
668-
throw new StaticGenBailoutError()
669-
}
660+
if (hasEmptyShell) {
661+
if (dynamicValidation.hasSuspenseAboveBody) {
662+
// This route has opted into allowing fully dynamic rendering
663+
// by including a Suspense boundary above the body. In this case
664+
// a lack of a shell is not considered disallowed so we simply return
665+
return
666+
}
670667

671-
if (clientDynamic.syncDynamicErrorWithStack) {
672-
// Just like above but within the client render...
673-
console.error(clientDynamic.syncDynamicErrorWithStack)
674-
throw new StaticGenBailoutError()
675-
}
668+
if (serverDynamic.syncDynamicErrorWithStack) {
669+
// There is no shell and the server did something sync dynamic likely
670+
// leading to an early termination of the prerender before the shell
671+
// could be completed.
672+
console.error(serverDynamic.syncDynamicErrorWithStack)
673+
// We terminate the build/validating render
674+
throw new StaticGenBailoutError()
675+
}
676676

677-
// We didn't have any sync bailouts but there may be user code which
678-
// blocked the root. We would have captured these during the prerender
679-
// and can log them here and then terminate the build/validating render
680-
const dynamicErrors = dynamicValidation.dynamicErrors
681-
if (dynamicErrors.length > 0) {
682-
for (let i = 0; i < dynamicErrors.length; i++) {
683-
console.error(dynamicErrors[i])
677+
if (clientDynamic.syncDynamicErrorWithStack) {
678+
// Just like above but within the client render...
679+
console.error(clientDynamic.syncDynamicErrorWithStack)
680+
throw new StaticGenBailoutError()
684681
}
685682

686-
throw new StaticGenBailoutError()
687-
}
683+
// We didn't have any sync bailouts but there may be user code which
684+
// blocked the root. We would have captured these during the prerender
685+
// and can log them here and then terminate the build/validating render
686+
const dynamicErrors = dynamicValidation.dynamicErrors
687+
if (dynamicErrors.length > 0) {
688+
for (let i = 0; i < dynamicErrors.length; i++) {
689+
console.error(dynamicErrors[i])
690+
}
688691

689-
// If we got this far then the only other thing that could be blocking
690-
// the root is dynamic Viewport. If this is dynamic then
691-
// you need to opt into that by adding a Suspense boundary above the body
692-
// to indicate your are ok with fully dynamic rendering.
693-
if (dynamicValidation.hasDynamicViewport) {
694-
console.error(
695-
`Route "${route}" has a \`generateViewport\` that depends on Request data (\`cookies()\`, etc...) or uncached external data (\`fetch(...)\`, etc...) without explicitly allowing fully dynamic rendering. See more info here: https://nextjs.org/docs/messages/next-prerender-dynamic-viewport`
696-
)
697-
throw new StaticGenBailoutError()
692+
throw new StaticGenBailoutError()
693+
}
694+
695+
// If we got this far then the only other thing that could be blocking
696+
// the root is dynamic Viewport. If this is dynamic then
697+
// you need to opt into that by adding a Suspense boundary above the body
698+
// to indicate your are ok with fully dynamic rendering.
699+
if (dynamicValidation.hasDynamicViewport) {
700+
console.error(
701+
`Route "${route}" has a \`generateViewport\` that depends on Request data (\`cookies()\`, etc...) or uncached external data (\`fetch(...)\`, etc...) without explicitly allowing fully dynamic rendering. See more info here: https://nextjs.org/docs/messages/next-prerender-dynamic-viewport`
702+
)
703+
throw new StaticGenBailoutError()
704+
}
705+
} else {
706+
if (
707+
dynamicValidation.hasAllowedDynamic === false &&
708+
dynamicValidation.hasDynamicMetadata
709+
) {
710+
console.error(
711+
`Route "${route}" has a \`generateMetadata\` that depends on Request data (\`cookies()\`, etc...) or uncached external data (\`fetch(...)\`, etc...) when the rest of the route does not. See more info here: https://nextjs.org/docs/messages/next-prerender-dynamic-metadata`
712+
)
713+
throw new StaticGenBailoutError()
714+
}
698715
}
699716
}

0 commit comments

Comments
 (0)