Next.js force-dynamic: What It Costs and When You Need It
One line turned a 112 KiB static page into a server render, 820 prerendered pages to 819. Measured in a real build, plus the 8 routes that go dynamic with no config.
export const dynamic = "force-dynamic" makes Next.js render a route on the server for every request instead of prerendering it at build time. You need it only when a page must be fresh but never touches a request API such as cookies() or searchParams. This repository has zero force-dynamic exports, and eight routes render per request anyway.
Everything below is read out of real next build output for this codebase (Next.js 16.2.9, Turbopack), including one deliberate experiment where we added the line to a static page and rebuilt.
What the option does, and what it is equivalent to
dynamic is a route segment config export. It accepts four values:
| Value | Effect | Typical use |
|---|---|---|
"auto" (default) | Cache as much as possible; a request API or uncached fetch flips the route to dynamic | Almost everything |
"force-dynamic" | Render every request on the server; fetch() defaults to no-store | Fresh data with no request API in sight |
"force-static" | Prerender anyway; cookies(), headers() and useSearchParams() return empty values | A route handler that must stay a file |
"error" | Prerender, and fail the build if anything needs the request | A guard on pages that must stay static |
The Next.js docs describe force-dynamic as equivalent to setting every fetch() in the segment to { cache: 'no-store', next: { revalidate: 0 } } plus fetchCache = 'force-no-store'. That sentence matters more than the headline: it is a blunt switch for the whole segment, not a per-query one.
One caveat before you copy it. If you enable Cache Components (cacheComponents in next.config.ts), segment configs like dynamic are replaced by use cache and cacheLife, and the migration guide says force-dynamic is simply "not needed" because pages are dynamic by default. This codebase has not enabled that flag, so the classic behavior below applies.
What it costs: a measured experiment
We took /privacy, a plain legal page with no dynamic input, and appended one line:
// src/app/privacy/page.tsx (experiment, reverted)
export const dynamic = "force-dynamic";
Then ran npm run build and compared against the unmodified build:
| Measure | Before | With force-dynamic on /privacy |
|---|---|---|
| Prerendered pages | 820 | 819 |
/privacy in the route table | ○ (static) | ƒ (dynamic) |
ƒ routes | 8 | 9 |
.next/server/app/privacy.html | 114,811 bytes (112 KiB) | file no longer exists |
One line removed a 112 KiB HTML file from the CDN and replaced it with a server invocation on every visit. The page content did not change by a byte. That is the real price: you give up CDN-served HTML for every visitor and pay compute for it, in exchange for freshness the page did not need.
What is already dynamic here, with no config at all
The eight ƒ routes in this storefront's build:
ƒ /api/download
ƒ /api/webhooks/lemonsqueezy
ƒ /auth/callback
ƒ /dashboard
ƒ /dashboard/settings
ƒ /login
ƒ /reset-password
ƒ /signup
Not one of them declares force-dynamic. Each is dynamic because of what it does:
/login,/signup,/reset-passwordreadsearchParams(const { next: nextParam, error } = await searchParams;insrc/app/login/page.tsx)./dashboardsits undersrc/app/dashboard/layout.tsx, which callscreateClient()fromsrc/lib/supabase/server.ts, and that awaitscookies()to read the session.- The three route handlers are
ƒbecause they read the request.
If the code reads the request, you do not need to say so a second time. The framework notices. Adding force-dynamic to src/app/login/page.tsx would change nothing except a line someone has to wonder about later. (The full list of what flips a route is in the static rendering guide.)
The one place we use the opposite
The only dynamic export in src/ is the reverse:
// src/app/blog/feed.xml/route.ts
// Prerendered at build time like the rest of the marketing site — the feed
// only changes when a post is added, which requires a deploy anyway.
export const dynamic = "force-static";
Since Next.js 15, GET route handlers are not cached by default, so this line is what keeps the feed a prerendered file. It also makes cookies() and headers() return empty values if someone later adds one to the handler, instead of silently turning the feed dynamic. In the build output /blog/feed.xml is ○.
When you genuinely need force-dynamic
| Situation | Use force-dynamic? | Better option |
|---|---|---|
Page reads cookies(), headers() or searchParams | No, already dynamic | Nothing |
| Data changes every few minutes | Rarely | export const revalidate = 300 (ISR) |
| One query must be live, rest static | No | fetch(url, { cache: "no-store" }) on that call |
Page calls a database client that is not fetch | Yes | Often the only switch, since the client bypasses the fetch cache |
| Build fails because an external service is unreachable at build time | Yes | Fix the build env, or accept it |
| A client-only widget needs the URL | No | useSearchParams in a Client Component under <Suspense> |
The database-client row is the honest use case. Next.js caches fetch() calls, not arbitrary function calls. If a Server Component calls an ORM or SQL driver directly and shows data that changes, nothing tells the framework the page is unsafe to prerender, so it renders once at build time and serves that forever. force-dynamic (or reading a request API) is the fix.
How to check which mode a route is in
Do not trust the folder or the source; read the build. npm run build prints a route table with a legend: ○ static, ● SSG via generateStaticParams, ƒ dynamic. Ours ends with three lines that are worth memorising:
○ (Static) prerendered as static content
● (SSG) prerendered as static HTML (uses generateStaticParams)
ƒ (Dynamic) server-rendered on demand
Our whole build is 27 ○ rows, the ● blog and product segments, and the 8 ƒ rows above. When a pull request moves that count, someone changed a rendering mode, usually without meaning to. The experiment in this post is the cheapest way to see it: add the line, build, diff the table, revert. Takes about thirty seconds of build time here (compile 14s, static generation 10s).
If you want the habit enforced, dynamic = "error" on a marketing layout fails the build the moment anyone adds a request API underneath it, which is a better guard than a code comment.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Page shows build-time data in production | Nothing in it reads the request, so it was prerendered | Export dynamic = "force-dynamic" or set revalidate |
| Whole site slow after one change | force-dynamic in a root layout.tsx applies to every child route | Move it to the one segment that needs it |
○ in the table but you expected ƒ | Nothing in the route reads the request | Read request or cookies(), or force it |
| Marketing page stopped being static | Someone added searchParams or cookies() | Compare route tables between builds, as above |
force-dynamic ignored | cacheComponents is on | Use use cache and cacheLife instead |
| Feed or sitemap handler renders per request | GET handlers are uncached unless you opt in | dynamic = "force-static", as feed.xml does |
The cheapest regression check is the route table itself. Run npm run build, count the ƒ rows, and treat any new one on a marketing page as a bug until proven otherwise.
Frequently asked questions
Is force-dynamic the same as getServerSideProps?
Close, for rendering mode: both render per request. But getServerSideProps never existed in the App Router. The equivalent habit is reading cookies() or searchParams, or exporting force-dynamic when there is no request input. See the getServerSideProps migration.
Does force-dynamic hurt SEO?
Not directly. Crawlers receive the same HTML. The cost is latency and compute: a prerendered file answers from the CDN edge, a dynamic route waits for a server. On a content page that drags down the performance metrics Google measures.
Can I put it in a layout? Yes, and it cascades to every route below. That is rarely what you want. Put it on the one page.
Should I use it on pages behind login?
Not needed. Reading the session already makes them dynamic, which is why /dashboard here has no such line.
Templates built for static-first Next.js
If you want a marketing site where the route table stays almost entirely ○, start from a landing template that ships without request-dependent code: ASoc Surge, ASoc Synth and ASoc Tempo are all prerendered Next.js sets.
Browse the sets: Next.js landing page templates and Tailwind landing page templates. For the proxy that sits in front of all of this, see Content Security Policy in Next.js.
