</>CodeWithKarani

DynamicServerError: Every Way Next.js Opts a Route Out of Static Rendering

Karani GeoffreyKarani Geoffrey10 min read

You wanted fresh data on one page. You added cache: 'no-store' to a fetch, ran the build, and got this:

DynamicServerError: Dynamic server usage: no-store fetch https://api.example.com/orders /dashboard

You did not ask for anything dynamic. You asked for uncached. The build is telling you that a route you never thought about has quietly changed how the entire framework renders it.

Here is the part that is genuinely poorly documented: the App Router tries to prerender every route at build time, and a single request-time API anywhere in that route's render tree bails the whole route out of static rendering. Usually that happens silently and is exactly what you wanted. The error only surfaces in specific circumstances, and because nobody publishes the full list of triggers in one place, the standard reaction is to paste export const dynamic = 'force-dynamic' everywhere until the build passes. That works, in the sense that setting your house on fire solves a wasp problem.

the build is trying to prerender a route that needs a request.

  • Decide deliberately: if the route genuinely must render per request, declare it with export const dynamic = 'force-dynamic' rather than relying on implicit detection.
  • If most of the route is static, move the request-time part into a child component behind <Suspense> so the shell can still be sent immediately.
  • If the error is uncaught rather than a silent opt-out, you almost certainly called cookies() or headers() outside the async context, inside a setTimeout or an unawaited promise. Await it properly.
  • On Next.js 15.4 and later, next build --debug-prerender gives readable stacks and shows every prerender error at once instead of exiting on the first.

The exact error

Error: Dynamic server usage: Page couldn't be rendered statically because it used
`cookies`. See more info here: https://nextjs.org/docs/messages/dynamic-server-error

and the fetch variant, which is the one most people meet first:

DynamicServerError: Dynamic server usage: no-store fetch https://api.example.com/orders /dashboard

Most of the time you will not see either. You will just notice this in the build output and wonder when it changed:

Route (app)
┌ ○ /
├ ƒ /dashboard
└ ○ /pricing

○  (Static)   prerendered as static content
ƒ  (Dynamic)  server-rendered on demand

A route that used to be and is now ƒ is the same event without the error. It is worth reading that table on every build, because a route silently becoming dynamic is a performance regression nobody gets paged for.

Why the build even tries

At build time Next.js renders each route as though a request had arrived, except there is no request. No cookies, no headers, no URL, no client IP. It runs your components anyway and hopes they never ask for any of it.

When your code does ask, Next.js throws a DynamicServerError internally. That exception is not an error report, it is a control-flow signal. Next.js catches it, concludes "this route cannot be prerendered", and marks the route dynamic. The build continues and nothing is printed.

The error becomes visible in two situations, and telling them apart is the whole diagnosis:

  1. The signal escaped. Next.js binds request-time APIs to an async context tied to the render's call stack. If you call cookies() inside a setTimeout, or after an async operation whose promise you never awaited, the call happens on a different execution context. The throw is not caught by the machinery that was supposed to catch it, and it lands in your build log as a real error.
  2. You told the route it must be static. With export const dynamic = 'error', or output: 'export', the bail is not permitted, so the signal becomes a hard failure by design.
next build renders the route no request exists never asks for one cookies() / headers() / no-store Static HTML emitted marked as circle in the build table DynamicServerError thrown as an internal signal, not a report caught: route becomes dynamic escaped: build fails
The same exception is a normal part of the build and a hard failure, depending only on whether Next.js is still on the call stack to catch it.

Every trigger, in one table

This is the list that does not exist as a single doc page. Anything here, anywhere in a route's render tree including its layouts, opts the route out of static rendering.

TriggerWhereNotes
cookies()next/headersAsync since Next.js 15, must be awaited
headers()next/headersAsync since Next.js 15
draftMode()next/headersAsync since Next.js 15
connection()next/serverExplicitly waits for a request. Replaces unstable_noStore
searchParamspage propsA Promise since Next.js 15. Reading it makes the route dynamic
fetch(url, { cache: 'no-store' })anywhere in the treeAlso next: { revalidate: 0 }
unstable_noStore()next/cacheDeprecated in favour of connection()
export const dynamic = 'force-dynamic'segment configExplicit, and equivalent to fetchCache = 'force-no-store' plus no-store on every fetch
export const revalidate = 0segment configAlways dynamic, but leaves explicit force-cache fetches alone
export const fetchCache = 'force-no-store'segment configAlso 'default-no-store' and 'only-no-store'
Reading the Request in a Route Handlerroute.tsrequest.url, request.headers, request.cookies

Two things to internalise. First, a layout counts: one cookies() call in the root layout makes every route in the app dynamic, and this is the single most common way a whole application quietly loses static rendering. Second, without Partial Prerendering the granularity is the route, not the component.

What changed, and when

Much of the confusing advice online is correct for a version you are not running.

VersionBehaviour
Next.js 13 and 14fetch was cached by default. cache: 'no-store' was how you got fresh data, and it was the usual cause of this error.
Next.js 15fetch is not cached by default, so most no-store options are now redundant. cookies(), headers(), params and searchParams became async and must be awaited. connection() stabilised.
Next.js 15.4next build --debug-prerender added.
Next.js 16With Cache Components enabled, dynamic, dynamicParams, revalidate and fetchCache are removed entirely and caching becomes opt-in per component.

If you are copying an answer written before late 2024, check which of these it assumes. A lot of "add no-store" advice is now a no-op that leaves a confusing comment in your codebase.

Fixing it

Step 1: Read the route name in the message

The error names both the API and the route: Dynamic server usage: no-store fetch ... /dashboard. The route is the last part. Start there rather than in the component you were editing, because the culprit is frequently a shared layout.

Step 2: Get a readable stack

next build --debug-prerender

On 15.4 and later this disables server minification, generates server source maps, and keeps building past the first prerender error so you see all of them together. The stacks become readable enough to point at a specific file. Do not deploy artifacts built with this flag; it is a debugging mode.

Step 3: Decide what the route should be

This is a design decision, not an error to silence.

// app/dashboard/page.tsx
export const dynamic = 'force-dynamic'

Use this when the route genuinely cannot be prerendered: a logged-in dashboard, a cart, anything keyed to the visitor. Being explicit is better than relying on implicit detection, because implicit detection depends on which APIs happen to be reachable, and that changes when someone refactors a component three levels down.

The four values, since people guess at them:

  • 'auto' is the default: cache as much as possible without preventing components from opting into dynamic behaviour.
  • 'force-dynamic' renders per request.
  • 'force-static' forces prerendering and makes cookies, headers() and useSearchParams() return empty values, which is a real trap if you have not read that sentence.
  • 'error' forces prerendering and fails the build if anything uses a request-time API. Useful as a guard on marketing pages you never want going dynamic by accident.

Step 4: Keep the static shell where you can

If only one widget needs the request, do not condemn the whole page. Push it into a child component and wrap it in Suspense:

import { Suspense } from 'react'
import { cookies } from 'next/headers'

async function CartBadge() {
  const store = await cookies()
  return <span>{store.get('cart_count')?.value ?? '0'}</span>
}

export default function Page() {
  return (
    <main>
      <ProductGrid />
      <Suspense fallback={<span>-</span>}><CartBadge /></Suspense>
    </main>
  )
}

With Partial Prerendering enabled, the Suspense boundary is the seam: the shell is prerendered and only the boundary is filled at request time. Without it, the route is still rendered on demand, but the static parts stream first instead of the visitor waiting for the slowest query. On a connection where round trips cost real time, which is most of the world, that difference is felt.

Step 5: Fix the async context escape

This is the case that produces an uncaught DynamicServerError rather than a silent opt-out. Broken:

async function getCookieData() {
  return new Promise((resolve) =>
    setTimeout(async () => {
      const cookieStore = await cookies()   // outside the async context
      resolve(cookieStore.getAll())
    }, 1000)
  )
}

Fixed, by reading the request-time value on the original call stack and only then handing it to the deferred work:

async function getCookieData() {
  const cookieStore = await cookies()
  const cookieData = cookieStore.getAll()
  return new Promise((resolve) =>
    setTimeout(() => resolve(cookieData), 1000)
  )
}

The same rule covers promises you forgot to await, event handlers, and anything scheduled onto a later tick. Read request-time values first, synchronously with respect to the render, then do the asynchronous work.

Step 6: Use connection() when there is no request API to point at

Sometimes a route has no cookies or headers but still must not be frozen at build time, because it uses Math.random(), new Date(), or a synchronous database driver that would otherwise run during prerendering:

import { connection } from 'next/server'

export default async function Page() {
  await connection()          // prerendering stops here
  const rand = Math.random()
  return <span>{rand}</span>
}

This is the successor to unstable_noStore(), and it states the intent far more clearly than a no-store flag on an unrelated fetch.

Verification

Run the build and read the route table, which is the authoritative answer:

npm run build
Route (app)
┌ ○ /
├ ○ /pricing
├ ƒ /dashboard
└ ○ /blog/[slug]

Every ƒ should be a route you intended to be dynamic. If a marketing page is on that list, something in a shared layout is leaking.

Confirm from the filesystem too, since it removes all doubt about what was actually written to disk. A prerendered route leaves an HTML file under .next/server/app/; a dynamic one does not:

ls .next/server/app/*.html

Finally, guard the intent. Adding export const dynamic = 'error' to routes you want to stay static turns any future accidental regression into a failed build, which is a much better place to find out than a slow page in production.

What people get wrong

"Put force-dynamic in the root layout and the errors stop." They do. So does static rendering, for every page in the application, including the ones that were the reason you chose this framework. If a build is failing on one route, fix one route.

"cache: 'no-store' is all I need for fresh data." On Next.js 15 and later, fetch is not cached by default, so that option frequently does nothing at all except opt the route out of prerendering as a side effect. If you want the route rendered per request, say so with the segment config. If you want a specific fetch uncached on an older version, that is a different statement.

"It is a runtime error, so it only affects production traffic." It is a build-time prerender error. Nothing has served a request yet. Debugging it in the browser is time spent in the wrong place.

"Wrap the call in try/catch." Next.js uses the thrown DynamicServerError as its opt-out signal. A broad try/catch around code that calls a request-time API can swallow that signal and produce behaviour nobody can explain later. If you must catch around such code, re-throw anything you did not specifically expect.

"revalidate = 0 and force-dynamic are the same." Close, not identical. revalidate = 0 guarantees dynamic rendering and changes the default for fetches with no cache option, but leaves fetches that explicitly opt into force-cache or a positive revalidate as they are. force-dynamic is equivalent to setting fetchCache = 'force-no-store', which overrides those too.

When it is still broken

  1. A dependency is calling headers(). Analytics SDKs, auth helpers and feature-flag clients often read request-time APIs internally. Grep your node_modules for next/headers if a route is dynamic and you cannot see why.
  2. useSearchParams without Suspense. This produces a different error about a missing Suspense boundary with client-side rendering bailout, not DynamicServerError. The fix is a Suspense boundary around the client component that reads it.
  3. You are using output: 'export'. A fully static export has no server at all, so there is no dynamic rendering to fall back to. Every request-time API is a hard error by definition, and the fix is architectural rather than configurational.
  4. You upgraded to Next.js 16 with Cache Components on. The segment config options you are reaching for no longer exist in that mode. Follow the migration guide rather than trying to reconstruct the old behaviour.
  5. Different results locally and in CI. Environment variables read at module scope, or a data source reachable from your laptop but not from the build runner, change which code paths run during prerendering. Build in a container matching CI before concluding the framework is inconsistent.

The habit worth building: read the route table on every build the way you read a test summary. A route silently going from to ƒ costs you time on every request from that day onward, and nothing will ever tell you it happened. If the same route is also throwing hydration errors, the two are usually related through the same request-time value, and finding the real hydration mismatch is the other half of the job.

Frequently asked questions

What causes DynamicServerError in a Next.js build?
At build time Next.js renders every App Router route as if a request arrived, but there is no request. When your code calls a request-time API such as cookies(), headers(), searchParams or a no-store fetch, Next.js throws DynamicServerError as an internal signal, catches it, and marks the route dynamic. You only see the error when the signal escapes the async context, typically because the API was called inside a setTimeout or an unawaited promise, or when the route has been forced static.
What are all the things that make a Next.js App Router route dynamic?
cookies(), headers() and draftMode() from next/headers; connection() from next/server; reading the searchParams page prop; fetch with cache: 'no-store' or next: { revalidate: 0 }; unstable_noStore(); reading the Request object in a Route Handler; and the segment configs dynamic = 'force-dynamic', revalidate = 0 and fetchCache set to any no-store variant. Any of these anywhere in the route's tree, including its layouts, opts the whole route out of static rendering.
Should I just add export const dynamic = 'force-dynamic' to fix the build?
Only on routes that genuinely cannot be prerendered, such as a logged-in dashboard or a cart. Adding it to a root layout silences the error and simultaneously removes static rendering from every page in the application. If most of a route is static, a better fix is to move the request-time part into a child component wrapped in Suspense so the static shell still gets sent immediately.
Is cache: 'no-store' still needed in Next.js 15?
Usually not. From Next.js 15 onwards fetch requests are not cached by default, so no-store is often a no-op left over from Next.js 13 and 14 where fetch was cached by default. Its main remaining effect is opting the route out of prerendering as a side effect, which is better expressed directly with a route segment config or with connection().
#Next.js#App Router#Caching#SSR#React
Keep reading

Related articles