Next.js Dynamic Server Usage: Understanding the Static and Dynamic Boundary

Your build fails because a page tried to read a header. Here is what triggers dynamic rendering and how to decide what each route should be.

Share
Next.js Dynamic Server Usage: Understanding the Static and Dynamic Boundary. Abstract next.js illustration in orange and dark grey on debugly.dev

The short answer

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

At build time, Next.js tries to render your route to static HTML. Something in the tree called an API that only exists per request: cookies(), headers(), searchParams, draftMode(), or a fetch marked no-store.

Three choices:

  1. Make the route dynamic with export const dynamic = "force-dynamic" if it genuinely needs request data
  2. Move the dynamic part into a child component wrapped in Suspense, so the shell stays static
  3. Remove the dynamic dependency if it was accidental

Tested on Next.js 15.4 with the App Router.

The mental model

The App Router decides per route whether to render at build time or per request. That decision is not something you configure first, it is inferred from what your code does.

Anything that reads request specific state forces dynamic rendering, because there is no request at build time.

API Why it forces dynamic
cookies() Cookies are per request
headers() Headers are per request
searchParams prop Query string is per request
draftMode() Reads a cookie
fetch(..., { cache: "no-store" }) Explicitly opts out of caching
unstable_noStore() Same, explicitly
export const dynamic = "force-dynamic" Explicit
export const revalidate = 0 Same effect

The error at build time is Next.js telling you it tried to prerender and could not.

Finding what caused it

The error names the route and sometimes the API. It rarely names the component, which is the thing you need.

The dynamic call is often several layers down: a shared header component reading cookies for the theme, an analytics wrapper reading a header, a session helper called from a utility.

next build --debug

That gives a fuller trace. Failing that, grep the whole tree reachable from the route:

grep -rn "cookies()\|headers()\|noStore()\|no-store" app/ components/ lib/

The most common surprise is a shared layout. A single cookies() call in app/layout.tsx makes every route in the application dynamic, and the error surfaces on a page that looks innocent.

The three fixes

1. Accept that it is dynamic

For a dashboard, an account page, or anything behind authentication, dynamic is correct. Say so explicitly:

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

Declaring it rather than letting it be inferred is worth doing. It documents the intent and it stops a future refactor accidentally making the route static and breaking it.

2. Push the dynamic part into a boundary

The best fix when most of the page does not need request data.

// app/product/[slug]/page.tsx  (stays static)
import { Suspense } from "react";

export default async function ProductPage({ params }) {
  const product = await getProduct(params.slug);   // static, cached

  return (
    <>
      <ProductDetails product={product} />
      <Suspense fallback={<CartButtonSkeleton />}>
        <CartButton productId={product.id} />       {/* dynamic, streamed */}
      </Suspense>
    </>
  );
}
// components/CartButton.tsx
import { cookies } from "next/headers";

export default async function CartButton({ productId }) {
  const cart = await getCart((await cookies()).get("cart_id")?.value);
  return <button>{cart?.has(productId) ? "In cart" : "Add to cart"}</button>;
}

The page shell prerenders and gets served from the cache immediately. The dynamic piece streams in behind a Suspense boundary. This is Partial Prerendering, and it is the reason the App Router's model is worth the complexity, because you get a static page's time to first byte with a dynamic page's personalisation.

Note that in Next.js 15 cookies() and headers() are async and must be awaited. Forgetting the await produces a confusing type error rather than a runtime one, which catches people upgrading from 14.

3. Remove the accidental dependency

Sometimes the dynamic call is not needed.

// forces dynamic for a value that is the same for everyone
const locale = (await headers()).get("accept-language");

If you support three locales, route based localisation with /en, /fr, /de and generateStaticParams gives you three static pages instead of one dynamic one.

Similarly, reading a cookie to decide a theme forces the whole route dynamic. Setting the theme class with a small inline script before paint keeps the route static and avoids a hydration mismatch at the same time.

The related errors

Route couldn't be rendered statically because it used no-store fetch

A fetch with cache: "no-store". Ask whether it really needs to be uncached. next: { revalidate: 60 } keeps the route static and refreshes the data every minute, which is right for most content.

Page changed from static to dynamic at runtime

Usually a conditional dynamic call, where a code path only reachable in some conditions reads a header.

useSearchParams() should be wrapped in a suspense boundary

The client side equivalent. A Client Component using useSearchParams needs a Suspense boundary above it, or the whole page opts out of prerendering.

Verifying what you actually shipped

The build output tells you what each route is:

Route (app)                     Size  First Load JS
┌ ○ /                          1.2 kB        89 kB
├ ● /blog/[slug]               2.1 kB        90 kB
├ ƒ /dashboard                 3.4 kB        95 kB
└ ◐ /product/[slug]            2.8 kB        92 kB

○  Static
●  SSG with generateStaticParams
ƒ  Dynamic, server rendered on demand
◐  Partially prerendered

Read this after every build. A route that silently changed from to ƒ is a performance regression that nothing else will tell you about, and it happens easily when someone adds a call in a shared component.

Worth adding a check in CI that fails if a route you expect to be static becomes dynamic. Parsing that output is slightly awkward and it catches a real class of regression.

Deciding what a route should be

The question I ask for each route: is the HTML the same for every visitor?

Same for everyone, changes rarely: static, with generateStaticParams for dynamic segments. Marketing pages, blog posts, documentation.

Same for everyone, changes often: static with revalidation. Product listings, pricing that updates hourly.

Different per visitor, but only in parts: static shell plus Suspense boundaries around the personalised bits. Most ecommerce product pages fall here, and it is the highest value pattern in the App Router.

Different per visitor throughout: fully dynamic. Dashboards, account pages, anything behind a login.

The mistake I see most is treating the whole page as dynamic because one element is personalised. A product page with a cart indicator does not need to be dynamic. Only the cart indicator does, and the difference in time to first byte between those two options is substantial on a slow connection.