Repository: vercel/next.js
Pull request: [dynamicIO] Disallow only dynamic metadata

stacked on #78575 

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

Changed files:
filename: errors/next-prerender-dynamic-metadata.mdx
patch: @@ -0,0 +1,151 @@
+---
+title: Cannot access Request information or uncached data in `generateMetadata()` in an otherwise entirely static route
+---
+
+## Why This Error Occurred
+
+When `dynamicIO` is enabled, Next.js requires that `generateMetadata()` not depend on uncached data or Request data 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.
+
+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 depend on Request data or 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.
+
+## Possible Ways to Fix It
+
+To fix this issue, you must first determine your goal for the affected route.
+
+### Caching External Data
+
+If your metadata does not depend on any request data, then it may be possible for you to indicate that 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.
+
+Before:
+
+```jsx filename="app/.../page.tsx"
+import { cms } from './cms'
+
+export async function generateMetadata() {
+  // This data lookup is not cached at the moment so
+  // Next.js will interpret this as needing to be rendered
+  // on every request.
+  const { title } = await cms.getPageData('/.../page')
+  return {
+    title,
+  }
+}
+
+async function getPageText() {
+  'use cache'
+  const { text } = await cms.getPageData('/.../page')
+  return text
+}
+
+export default async function Page() {
+  // This text is cached so the main content of this route
+  // is prerenderable.
+  const text = await getPageText()
+  return <article>{text}</article>
+}
+```
+
+After:
+
+```jsx filename="app/.../page.tsx"
+import { cms } from './cms'
+
+export async function generateMetadata() {
+  // By marking this function as cacheable, Next.js
+  // can now include it in the prerender for this route.
+  'use cache'
+  const { title } = await cms.getPageData('/.../page')
+  return {
+    title,
+  }
+}
+
+async function getPageText() {
+  'use cache'
+  const { text } = await cms.getPageData('/.../page')
+  return text
+}
+
+export default async function Page() {
+  // This text is cached so the main content of this route
+  // is prerenderable.
+  const text = await getPageText()
+  return <article>{text}</article>
+}
+```
+
+### If you must access Request Data or your external data is uncacheable
+
+If your metadata requires Request 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 need for Request data, you can indicate an intentional dependency on a Request with `await connection()`. This is like telling Next.js that this component is never prerenderable and must be rendered on every user request.
+
+Before:
+
+```jsx filename="app/.../page.tsx"
+import { cookies } from 'next/headers'
+import { getPersonalizedTitle } from './my-api'
+
+export async function generateMetadata() {
+  // In this example, we are assuming we must fetch our title
+  // from a protected external API. While the response is potentially
+  // cacheable, the it still requires accessing a token from the Request cookies.
+  const token = (await cookies()).get('token')
+  const response = await getPersonalizedTitle(token)
+  return {
+    title: getTitle(response),
+  }
+}
+
+export default function Page() {
+  return <article>This article is completely static</article>
+}
+```
+
+After:
+
+```jsx filename="app/.../page.tsx"
+import { Suspense } from 'react'
+import { cookies } from 'next/headers'
+import { connection } from 'next/server'
+
+export async function generateMetadata() {
+  const token = (await cookies()).get('token')
+  const response = await fetch(..., { headers: { Authorization: token } })
+  return {
+    title: getTitle(response),
+  }
+}
+
+async function DynamicMarker() {
+  // This component renders nothing, but it will always
+  // be dynamic because it waits for an actual connection.
+  const Connection = async () => {
+    await connection()
+    return null
+  }
+  return (
+    <Suspense>
+      <Connection />
+    </Suspense>
+  )
+}
+
+export default function Page() {
+  return (
+    <>
+      <article>This article is completely static</article>
+      {/* Rendering this DynamicMarker component tells Next.js that
+          this Page has some dynamic content. */}
+      <DynamicMarker />
+    </>
+  )
+}
+```
+
+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, and only the metadata will stream in dynamically.
+
+## Useful Links
+
+- [`generateMetadata()`](/docs/app/api-reference/functions/generate-metadata)
+- [`connection()`](/docs/app/api-reference/functions/connection)
+- [`cookies()`](/docs/app/api-reference/functions/cookies)
+- [`"use cache"`](/docs/app/api-reference/directives/use-cache)

filename: errors/next-prerender-dynamic-viewport.mdx
patch: @@ -104,6 +104,6 @@ export default async function Layout({ children }) {
 
 ## Useful Links
 
-- [`generateViewport()`](docs/app/api-reference/functions/generate-viewport)
-- [`cookies()`](docs/app/api-reference/functions/cookies)
+- [`generateViewport()`](/docs/app/api-reference/functions/generate-viewport)
+- [`cookies()`](/docs/app/api-reference/functions/cookies)
 - [`"use cache"`](/docs/app/api-reference/directives/use-cache)

filename: packages/next/src/server/app-render/app-render.tsx
patch: @@ -132,7 +132,7 @@ import {
   createDynamicValidationState,
   getFirstDynamicReason,
   trackAllowedDynamicAccess,
-  throwIfDisallowedEmptyStaticShell,
+  throwIfDisallowedDynamic,
   consumeDynamicAccess,
   type DynamicAccess,
 } from './dynamic-rendering'
@@ -2553,14 +2553,13 @@ async function spawnDynamicValidationInDev(
       // If we've disabled throwing on empty static shell, then we don't need to
       // track any dynamic access that occurs above the suspense boundary because
       // we'll do so in the route shell.
-      if (preludeIsEmpty && !ctx.renderOpts.doNotThrowOnEmptyStaticShell) {
-        throwIfDisallowedEmptyStaticShell(
-          route,
-          dynamicValidation,
-          serverDynamicTracking,
-          clientDynamicTracking
-        )
-      }
+      throwIfDisallowedDynamic(
+        route,
+        preludeIsEmpty,
+        dynamicValidation,
+        serverDynamicTracking,
+        clientDynamicTracking
+      )
     } catch {}
     return null
   }
@@ -3091,9 +3090,10 @@ async function prerenderToStream(
         // If we've disabled throwing on empty static shell, then we don't need to
         // track any dynamic access that occurs above the suspense boundary because
         // we'll do so in the route shell.
-        if (preludeIsEmpty && !ctx.renderOpts.doNotThrowOnEmptyStaticShell) {
-          throwIfDisallowedEmptyStaticShell(
+        if (!ctx.renderOpts.doNotThrowOnEmptyStaticShell) {
+          throwIfDisallowedDynamic(
             workStore.route,
+            preludeIsEmpty,
             dynamicValidation,
             serverDynamicTracking,
             clientDynamicTracking
@@ -3577,10 +3577,11 @@ async function prerenderToStream(
         // If we've disabled throwing on empty static shell, then we don't need to
         // track any dynamic access that occurs above the suspense boundary because
         // we'll do so in the route shell.
-        if (preludeIsEmpty && !ctx.renderOpts.doNotThrowOnEmptyStaticShell) {
+        if (!ctx.renderOpts.doNotThrowOnEmptyStaticShell) {
           // We don't have a shell because the root errored when we aborted.
-          throwIfDisallowedEmptyStaticShell(
+          throwIfDisallowedDynamic(
             workStore.route,
+            preludeIsEmpty,
             dynamicValidation,
             serverDynamicTracking,
             clientDynamicTracking

filename: packages/next/src/server/app-render/dynamic-rendering.ts
patch: @@ -80,6 +80,7 @@ export type DynamicValidationState = {
   hasSuspenseAboveBody: boolean
   hasDynamicMetadata: boolean
   hasDynamicViewport: boolean
+  hasAllowedDynamic: boolean
   dynamicErrors: Array<Error>
 }
 
@@ -99,6 +100,7 @@ export function createDynamicValidationState(): DynamicValidationState {
     hasSuspenseAboveBody: false,
     hasDynamicMetadata: false,
     hasDynamicViewport: false,
+    hasAllowedDynamic: false,
     dynamicErrors: [],
   }
 }
@@ -623,11 +625,13 @@ export function trackAllowedDynamicAccess(
   } else if (hasSuspenseAfterBodyOrHtmlRegex.test(componentStack)) {
     // This prerender has a Suspense boundary above the body which
     // effectively opts the page into allowing 100% dynamic rendering
+    dynamicValidation.hasAllowedDynamic = true
     dynamicValidation.hasSuspenseAboveBody = true
     return
   } else if (hasSuspenseRegex.test(componentStack)) {
     // this error had a Suspense boundary above it so we don't need to report it as a source
     // of disallowed
+    dynamicValidation.hasAllowedDynamic = true
     return
   } else {
     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(
   return error
 }
 
-export function throwIfDisallowedEmptyStaticShell(
+export function throwIfDisallowedDynamic(
   route: string,
+  hasEmptyShell: boolean,
   dynamicValidation: DynamicValidationState,
   serverDynamic: DynamicTrackingState,
   clientDynamic: DynamicTrackingState
 ): void {
-  if (dynamicValidation.hasSuspenseAboveBody) {
-    // This route has opted into allowing fully dynamic rendering
-    // by including a Suspense boundary above the body. In this case
-    // a lack of a shell is not considered disallowed so we simply return
-    return
-  }
-
-  if (serverDynamic.syncDynamicErrorWithStack) {
-    // There is no shell and the server did something sync dynamic likely
-    // leading to an early termination of the prerender before the shell
-    // could be completed.
-    console.error(serverDynamic.syncDynamicErrorWithStack)
-    // We terminate the build/validating render
-    throw new StaticGenBailoutError()
-  }
+  if (hasEmptyShell) {
+    if (dynamicValidation.hasSuspenseAboveBody) {
+      // This route has opted into allowing fully dynamic rendering
+      // by including a Suspense boundary above the body. In this case
+      // a lack of a shell is not considered disallowed so we simply return
+      return
+    }
 
-  if (clientDynamic.syncDynamicErrorWithStack) {
-    // Just like above but within the client render...
-    console.error(clientDynamic.syncDynamicErrorWithStack)
-    throw new StaticGenBailoutError()
-  }
+    if (serverDynamic.syncDynamicErrorWithStack) {
+      // There is no shell and the server did something sync dynamic likely
+      // leading to an early termination of the prerender before the shell
+      // could be completed.
+      console.error(serverDynamic.syncDynamicErrorWithStack)
+      // We terminate the build/validating render
+      throw new StaticGenBailoutError()
+    }
 
-  // We didn't have any sync bailouts but there may be user code which
-  // blocked the root. We would have captured these during the prerender
-  // and can log them here and then terminate the build/validating render
-  const dynamicErrors = dynamicValidation.dynamicErrors
-  if (dynamicErrors.length > 0) {
-    for (let i = 0; i < dynamicErrors.length; i++) {
-      console.error(dynamicErrors[i])
+    if (clientDynamic.syncDynamicErrorWithStack) {
+      // Just like above but within the client render...
+      console.error(clientDynamic.syncDynamicErrorWithStack)
+      throw new StaticGenBailoutError()
     }
 
-    throw new StaticGenBailoutError()
-  }
+    // We didn't have any sync bailouts but there may be user code which
+    // blocked the root. We would have captured these during the prerender
+    // and can log them here and then terminate the build/validating render
+    const dynamicErrors = dynamicValidation.dynamicErrors
+    if (dynamicErrors.length > 0) {
+      for (let i = 0; i < dynamicErrors.length; i++) {
+        console.error(dynamicErrors[i])
+      }
 
-  // If we got this far then the only other thing that could be blocking
-  // the root is dynamic Viewport. If this is dynamic then
-  // you need to opt into that by adding a Suspense boundary above the body
-  // to indicate your are ok with fully dynamic rendering.
-  if (dynamicValidation.hasDynamicViewport) {
-    console.error(
-      `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`
-    )
-    throw new StaticGenBailoutError()
+      throw new StaticGenBailoutError()
+    }
+
+    // If we got this far then the only other thing that could be blocking
+    // the root is dynamic Viewport. If this is dynamic then
+    // you need to opt into that by adding a Suspense boundary above the body
+    // to indicate your are ok with fully dynamic rendering.
+    if (dynamicValidation.hasDynamicViewport) {
+      console.error(
+        `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`
+      )
+      throw new StaticGenBailoutError()
+    }
+  } else {
+    if (
+      dynamicValidation.hasAllowedDynamic === false &&
+      dynamicValidation.hasDynamicMetadata
+    ) {
+      console.error(
+        `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`
+      )
+      throw new StaticGenBailoutError()
+    }
   }
 }

filename: test/e2e/app-dir/dynamic-io-errors/dynamic-io-errors.test.ts
patch: @@ -71,20 +71,18 @@ function runTests(options: { withMinification: boolean }) {
 
       // This test used to assert the opposite but now that metadata is streaming during prerenders
       // we don't have to error the build when it is dynamic
-      it('should not error the build if generateMetadata is dynamic', async () => {
+      it('should error the build if generateMetadata is dynamic when the rest of the route is prerenderable', async () => {
         try {
           await next.start()
         } catch {
-          throw new Error('expected build not to fail')
+          // we expect the build to fail
         }
+        const expectError = createExpectError(next.cliOutput)
 
-        if (WITH_PPR) {
-          expect(next.cliOutput).toContain('◐ / ')
-        } else {
-          expect(next.cliOutput).toContain('ƒ / ')
-        }
-        const $ = await next.render$('/')
-        expect($('#sentinel').text()).toBe('sentinel')
+        expectError(
+          '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'
+        )
+        expectError('Error occurred prerendering page "/"')
       })
     })
     describe('Dynamic Metadata - Error Route', () => {
@@ -162,16 +160,14 @@ function runTests(options: { withMinification: boolean }) {
         try {
           await next.start()
         } catch {
-          throw new Error('expected build not to fail')
+          // we expect the build to fail
         }
+        const expectError = createExpectError(next.cliOutput)
 
-        if (WITH_PPR) {
-          expect(next.cliOutput).toContain('◐ / ')
-        } else {
-          expect(next.cliOutput).toContain('ƒ / ')
-        }
-        const $ = await next.render$('/')
-        expect($('#sentinel').text()).toBe('sentinel')
+        expectError(
+          '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'
+        )
+        expectError('Error occurred prerendering page "/"')
       })
     })