Skip to content

Next.js error

Route Couldn't Be Rendered Statically Because It Used Cookies

The error

Dynamic server usage: Route /dashboard couldn't be rendered statically because it used `cookies`.

One call to cookies(), headers() or an uncached fetch takes the whole route out of static rendering. How to find which call did it, and keep the rest of the page static.

cookies(), headers(), draftMode(), searchParams, and any fetch marked no-store are request-time APIs. They cannot produce a value at build time, because at build time there is no request. A component that calls one cannot be prerendered, and since a page is one tree, neither can anything that renders it.

The message is Next.js naming the call that forced the decision.

The cost, concretely

A static route is HTML on a CDN edge node: single-digit milliseconds, no compute, unaffected by how many people ask for it at once. A dynamic route runs your component tree, your data layer and your database on every request.

The difference is not a percentage. It shows up in Largest Contentful Paint, in your infrastructure bill, and in what happens when a link works.

So when a route goes dynamic for a reason you did not choose, that is worth finding.

Find the call

The build output is the map. Every route is marked:

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

Read it after a build and look for ƒ where you expected . The error message names the API; what it does not name is which component called it, and in a real codebase that is the hard part - the call is usually three levels down in something shared.

Two patterns account for most of it:

A session read in a shared layout. app/(app)/layout.tsx reads a cookie to decide whether to show a sign-in button. Every page under that layout is now dynamic, including the marketing pages that live in the same group and have no user-specific content at all.

An analytics or feature-flag helper. A utility called from a header component reads headers() for the user agent or a geo header. One import, and the whole subtree below it pays.

The fix

Push the request-time read down to the smallest component that needs it, and put a Suspense boundary above it so the rest of the tree can be prerendered:

// layout.tsx - static again
import { Suspense } from 'react';
 
export default function Layout({ children }: { children: React.ReactNode }) {
  return (
    <>
      <nav>
        <Logo />
        <Suspense fallback={<SignInSkeleton />}>
          <AccountMenu />   {/* only this reads cookies() */}
        </Suspense>
      </nav>
      {children}
    </>
  );
}

The shell prerenders. AccountMenu streams in. The pages under the layout go back to being static.

Where the value is genuinely needed for the whole page - a dashboard that means nothing without a session - dynamic rendering is the correct answer and there is nothing to fix. Make that an explicit decision rather than an inherited one.

What looks like a fix and is not

export const dynamic = 'force-static'. This does not make the route static. It makes the build fail, or in some cases makes cookies() return empty values, which produces a page rendered as though nobody is signed in. You have traded an informational message for a correctness bug.

Moving the read into a client component. The cookie read now happens in the browser, so the route prerenders - and the personalised part arrives after hydration, with a flash of the wrong state in between. For a signed-in indicator that flash is exactly what users notice.

Caching around it. Wrapping a request-time API in unstable_cache or a revalidate does not change the fact that it needs a request. The four caching layers do different jobs, and none of them is a substitute for this decision - which one you are arguing with is worth knowing before you reach for one.

The same mechanism runs in reverse: middleware that reads a cookie runs on every matched request, including static assets, so a broad matcher can cost you a round trip on files that should never have touched your server. Middleware runs on every request, including the ones you forgot.

If nobody has read your build output in a while, the count of ƒ where you expected is the fastest measure of how much this is costing you - and it is the first thing an audit looks at.

Related questions

Is this an error or is it telling me what it did?
Both, depending on where you see it. During a normal build it is informational - the route went dynamic and Next.js is saying why. With force-static set, or in a generateStaticParams path, it is a hard failure, because you asked for something impossible.
We do need the cookie. Is dynamic rendering just correct here?
For the part that reads it, yes. The question is whether the rest of the route needed to come with it - a session read in a shared layout takes every page beneath it dynamic, including the ones with no personalisation at all.