Static Rendering in the Next.js App Router: What Silently Turns a Page Dynamic
Four App Router features opt a page out of static rendering with no error and no warning. The only place it shows is one character in the build output.
A page falls out of static rendering the moment anything in its tree reads something that only exists per request — searchParams, cookies(), headers(), or a middleware value. Next.js does not warn you. The only place it shows is one character in the build output: ƒ instead of ○ or ●. Read that legend after every build.
This storefront prerenders 111 product pages, 7 category hubs, 42 blog posts and a per-post Open Graph card for each of them. Four separate things tried to make parts of that dynamic. We hit two by accident, refused one deliberately, and designed around the fourth. Here is each one, how it presents, and what it costs.
First: learn to read the legend
Every next build ends with a route table, and the symbol in front of each route is the only feedback you get:
○ (Static) prerendered as static content
● (SSG) prerendered as static HTML (uses generateStaticParams)
ƒ (Dynamic) server-rendered on demand
That is the whole diagnostic surface. There is no error, no lint rule and no runtime warning for accidentally going dynamic — because in most cases it is a legitimate thing to do, and the framework cannot tell your intent from your code. If you care about static output, diffing this table is a build step, not a curiosity.
Here is our table, trimmed:
├ ○ /pricing
├ ○ /nextjs-landing-page-template
├ ● /templates/[slug]
│ ├ /templates/asoc-admin
│ └ [+108 more paths]
├ ƒ /login
├ ƒ /signup
├ ƒ /reset-password
├ ƒ /dashboard
└ ƒ /dashboard/settings
Five dynamic routes, all of them on purpose. The rest is files on a CDN.
Trigger 1: a metadata route under a dynamic segment
This is the one that cost us real work, because it is invisible in every way except the build table.
app/blog/[slug]/opengraph-image.tsx sits next to app/blog/[slug]/page.tsx. The page declares generateStaticParams. It is natural to assume the image route inherits those params — it is in the same folder, for the same routes.
It does not. The comment we left on the fix says it plainly:
// A metadata route under a dynamic segment is its own route, so it needs its
// own params even though the page beside it already declares them. Without
// this the card is rendered on demand per request (`ƒ` in the build output)
// instead of being baked at build time like every other page on the site.
export function generateStaticParams() {
return getPublishedSlugs().map((slug) => ({ slug }));
}
The symptom is nothing. The card renders correctly. It looks right in every preview tool. What actually happens is that every scrape by every social platform and every AI crawler boots a serverless function that runs Satori to rasterize a 1200×630 PNG — work that should have happened once, at build time, for a file that never changes between deploys.
The same applies to icon.tsx, apple-icon.tsx and twitter-image.tsx under a dynamic segment. Check each one in the route table.
One related gotcha in the same file, worth knowing before you reach for it: generateImageMetadata exists to emit several images for one segment, and using it moves the route under a [__metadata_id__] child that has to resolve its own params. If you want one image per route, use the plain alt / size / contentType exports.
Trigger 2: searchParams in a page
Reading searchParams in a Server Component page makes that route dynamic. Necessarily so — the params are part of the request, and there is no way to prerender a page whose content depends on a value that does not exist until someone asks.
Ours:
export default async function LoginPage({
searchParams,
}: {
searchParams: Promise<{ next?: string; error?: string }>;
}) {
const { next: nextParam, error } = await searchParams;
const next = safeNext(nextParam);
That is why /login, /signup and /reset-password are ƒ in the table above. It is the right call for those three: they are noindex, they are never a first paint from search, and the ?next= round-trip is what makes the redirect-after-login work.
But notice how easily this spreads. Any page that reads a ?tab=, a ?sort=, a ?page= or a UTM parameter server-side goes dynamic, including a marketing page you very much wanted on a CDN. Three ways out, in order of preference:
- Read it on the client. Put the parameter-dependent bit in a Client Component using
useSearchParams, wrapped in<Suspense>. The shell prerenders; the client fills in the detail. - Make it a real route.
?tab=pricingwants to be/pricing. Anything with a distinct set of values and any SEO value at all should be a path, not a query. (Which values deserve a URL at all is its own decision — indexable facet combinations are a crawl trap.) - Accept dynamic, deliberately, on pages where it is correct — which is what we did.
The failure mode to avoid is number four: accepting it without noticing.
Trigger 3: a per-response value from middleware
Middleware itself does not make pages dynamic. Middleware that produces a value the page must read does.
The canonical example is a CSP nonce. It must be unique per response, the page has to read that request's nonce, and the route therefore cannot be a prerendered file. Apply the documented recipe across a marketing site and you have traded your entire static build for one header.
We refused it for exactly that reason and put the policy in next.config.ts instead, which is a static header on a static file. The full argument — including why the obvious connect-src addition for analytics turned out to be unnecessary — is in the CSP post.
The general rule: middleware may inspect and redirect freely; the moment a page reads something middleware computed, that page is dynamic. Our own proxy refreshes the Supabase session cookie on every request and no page reads anything from it, so every page stays static.
While you are there, check the matcher. Ours excludes static assets and the crawler routes:
matcher: [
"/((?!_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt|.*\\.(?:svg|png|jpg|jpeg|webp|gif|ico|webmanifest|html)$).*)",
],
Running a session refresh on sitemap.xml costs a function invocation on every crawler hit and does nothing.
Trigger 4: cookies() or headers() in a shared layout
This is the most destructive of the four because of where it lands. A cookies() call in app/layout.tsx — or in any component that layout renders — makes every page in the application dynamic. One line, whole site.
It is also the most tempting, because "show the user's name in the header if they are signed in" is a completely reasonable requirement, and reading the session in the root layout is the obvious way to do it.
We do not, and the reason is worth copying. The site-wide header renders identically for everyone; the account-dependent parts are a Client Component that resolves after hydration. That keeps every marketing page a static file while still showing signed-in state.
There is a bundle-size version of this same mistake, which we did make: the header imported the Supabase browser client at module scope, putting ~68 KiB gzipped / 255 KiB parsed of auth SDK into the initial bundle of the home page, the blog and the docs — pages with no account UI at all. The fix was a dynamic import behind a cookie probe. Different failure, same root cause: auth is request-shaped, and anything request-shaped in a shared layout spreads to everything under it. The measured version of that story is in Supabase vs Firebase.
Two things worth turning on
dynamicParams = false — say that nothing outside generateStaticParams exists:
export const dynamicParams = false;
Without it, a request for /blog/anything-at-all attempts an on-demand render. With it, unknown params 404 immediately. It converts a class of bad URLs from "boot a function and probably error" into a static 404, and it documents the intent.
force-static on hand-written routes — a Route Handler is dynamic by default. Ours that serves the RSS feed builds from the same registry the pages use and changes only on deploy:
export const dynamic = "force-static";
If a handler's output depends only on data in the repo, say so.
The one that is not a rendering mode but costs the same
Worth naming because it hides in the same place: the request-time image optimizer. Pages are static, and every next/image still routes through an optimization endpoint on first request per size per format. That is a per-request cost and, on most hosts, a per-image bill.
We generate WebP derivatives at build time instead and render plain <img> with the right file: -card.webp at 1060w for grid cards, -view.webp at 1600w for the detail frame. Cover images went 255 KiB → 33 KiB, and there is no optimizer in the request path at all. This is a trade — you give up automatic responsive sizing and you have to run the build script when you add an image — and it is the right one for a catalog of fixed, known images. It is the wrong one for user-uploaded content. The full comparison is in the image pipeline post.
What static rendering does and does not buy you
Be honest about the ceiling, because "make it static" gets quoted as a performance fix and it is only half of one.
We took this site to a desktop Lighthouse performance score of 100 across eight page types. Mobile lands at 89–99. Everything above is done, and mobile is still not 100.
So we tested the obvious hypothesis. Blocking every image and every RSC prefetch on the home page moved LCP from 3786 ms to 3740 ms — 46 milliseconds. The remaining cost is not the network and not the HTML; it is React hydration, and the largest single chunk is react-dom at 221 KiB raw / 70 KiB gzipped. That is the floor for an interactive React page.
Static rendering removes server latency, removes cold starts, and makes your hosting bill flat. It does not remove the framework. If your target is a 100 on a mid-range phone, the remaining work is shipping less interactivity, not more caching.
Mistakes and how they show up
| Mistake | Symptom | Fix |
|---|---|---|
Metadata route without its own generateStaticParams | ƒ on the image route; a function boots per social scrape | Declare params on the image route too |
searchParams read in a page for one small feature | Whole route dynamic, no error | useSearchParams in a Client Component inside <Suspense> |
| Nonce-in-middleware CSP on a marketing site | Every matched route dynamic; SSG build gone | Static policy in next.config.ts |
cookies() in the root layout | Entire application dynamic from one line | Resolve account state client-side |
| Auth SDK imported at module scope in a shared component | 68 KiB gzipped on pages with no account UI | Dynamic import behind a cheap probe |
| Route Handler serving repo data | Dynamic by default, invoked per request | export const dynamic = "force-static" |
Middleware matcher covering sitemap.xml and assets | Invocations on every crawler hit | Exclude crawler routes and static files |
| Never reading the build table | All of the above, indefinitely | Diff the route legend on every build |
Frequently asked questions
Does one dynamic page hurt the static ones?
No. Rendering mode is per route. A dynamic /dashboard costs nothing to a static /pricing. The exception is the layout rule above: dynamic behaviour in a shared layout is not per route, it is per subtree, and the root layout's subtree is everything.
Is export const dynamic = "force-static" a fix for an accidentally dynamic page?
No, it is an assertion, and it will fail the build if the page really does read request data. That is useful — use it on routes you believe are static to make regressions loud. It cannot make a genuinely request-dependent page static.
What about revalidate and ISR?
Orthogonal, and a good answer when content changes on a schedule you do not control. We do not use it: everything here changes on deploy, so a full static build is simpler and there is no staleness window to reason about. If your data comes from a CMS your editors update, ISR is exactly the feature you want.
How do I check this in CI rather than by eye?
Capture the route table from the build log and fail on unexpected ƒ routes. An allowlist of routes you have accepted as dynamic is about ten lines of script, and it turns a silent regression into a red build. We check ours by hand today; it belongs in CI.
Are Server Components enough to keep a page static?
No — they are unrelated. A Server Component can read cookies() and go dynamic; a page full of Client Components can prerender perfectly. The question is only whether anything in the tree reads per-request data.
Templates that stay static while looking live
Marketing sites for trading, rates and AI tooling are the hardest version of this, because the design wants live-looking widgets on a page that has every reason to be a static file. The templates below take that shape — a prerendered shell with the moving parts resolving client-side, so the page is on a CDN and the widget still ticks.
