Skip to main content
ASoc
Tutorial

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.

The ASoc Team7 min read

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:

ValueEffectTypical use
"auto" (default)Cache as much as possible; a request API or uncached fetch flips the route to dynamicAlmost everything
"force-dynamic"Render every request on the server; fetch() defaults to no-storeFresh data with no request API in sight
"force-static"Prerender anyway; cookies(), headers() and useSearchParams() return empty valuesA route handler that must stay a file
"error"Prerender, and fail the build if anything needs the requestA 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:

MeasureBeforeWith force-dynamic on /privacy
Prerendered pages820819
/privacy in the route table○ (static)ƒ (dynamic)
ƒ routes89
.next/server/app/privacy.html114,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-password read searchParams (const { next: nextParam, error } = await searchParams; in src/app/login/page.tsx).
  • /dashboard sits under src/app/dashboard/layout.tsx, which calls createClient() from src/lib/supabase/server.ts, and that awaits cookies() 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

SituationUse force-dynamic?Better option
Page reads cookies(), headers() or searchParamsNo, already dynamicNothing
Data changes every few minutesRarelyexport const revalidate = 300 (ISR)
One query must be live, rest staticNofetch(url, { cache: "no-store" }) on that call
Page calls a database client that is not fetchYesOften the only switch, since the client bypasses the fetch cache
Build fails because an external service is unreachable at build timeYesFix the build env, or accept it
A client-only widget needs the URLNouseSearchParams 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

SymptomCauseFix
Page shows build-time data in productionNothing in it reads the request, so it was prerenderedExport dynamic = "force-dynamic" or set revalidate
Whole site slow after one changeforce-dynamic in a root layout.tsx applies to every child routeMove it to the one segment that needs it
○ in the table but you expected ƒNothing in the route reads the requestRead request or cookies(), or force it
Marketing page stopped being staticSomeone added searchParams or cookies()Compare route tables between builds, as above
force-dynamic ignoredcacheComponents is onUse use cache and cacheLife instead
Feed or sitemap handler renders per requestGET handlers are uncached unless you opt indynamic = "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.

Keep reading

Tutorial13 min read

Gated File Downloads in Next.js: The Order the Checks Must Run In

Authenticate, verify, validate, authorize, rate-limit and record — then sign. The atomic limiter that closes the count-then-insert race, and what fails closed versus open.

Read more