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.
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:
- Make the route dynamic with
export const dynamic = "force-dynamic"if it genuinely needs request data - Move the dynamic part into a child component wrapped in Suspense, so the shell stays static
- 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.