Next.js App Router Rendering Choices — Per-Route Control
Next.js App Router lets you set rendering behavior per route segment via exported constants and routeRules. The senior knowledge: how to opt routes into static, dynamic, ISR, edge, partial pre-rendering (PPR); how the fetch cache interacts; caching directives (cache:, next.revalidate, next.tags); and how Next 15+ changed defaults (caching is now opt-in, not opt-out).
How do you set a route’s rendering mode?
Export constants from page.tsx/layout.tsx/route.ts:
// app/posts/[id]/page.tsx
export const dynamic = "force-static"; // or "force-dynamic", "auto", "error"
export const revalidate = 60; // ISR — seconds between regenerations
export const runtime = "edge"; // or "nodejs" (default)
export const fetchCache = "default-cache"; // fine-grained cache control
export const dynamicParams = true; // allow on-demand SSG of new paramsWhat each does:
| Export | Effect |
|---|---|
dynamic = "auto" (default) |
Next decides based on what your code does (uses cookies(), headers(), etc → dynamic) |
dynamic = "force-dynamic" |
always rendered per-request (SSR) |
dynamic = "force-static" |
always rendered at build (SSG) |
dynamic = "error" |
throw if a dynamic operation is detected (catches accidental SSR) |
revalidate = N |
ISR — at most one regeneration per N seconds |
revalidate = false (default) |
no ISR — pure static (until next build) |
runtime = "edge" |
run on edge runtime (V8 isolate, faster cold start, limited APIs) |
runtime = "nodejs" |
full Node runtime (default) |
What triggers “dynamic” rendering automatically?
Next.js renders the route dynamically (per-request) if your code uses:
cookies()— reading cookies (per-user).headers()— reading request headers (per-request).searchParamsprop (in App Router) — depends on URL.fetch(url, { cache: "no-store" })— explicit non-cached fetch.- Server Actions (rendering after a mutation).
Once any of these is detected, the route can’t be pre-rendered statically — it’s per-request SSR.
This is why a route that should be static may surprise you with “this route is dynamic” — usually a hidden cookies() call in a deep dependency.
ISR — show me.
// app/blog/[slug]/page.tsx
export const revalidate = 3600; // 1 hour
export async function generateStaticParams() {
const posts = await db.posts.findMany({ select: { slug: true } });
// pre-render at build
return posts.map(p => ({ slug: p.slug }));
}
export default async function Post({ params }: { params: { slug: string } }) {
const post = await db.posts.findUnique({ where: { slug: params.slug } });
return <Article post={post} />;
}Behavior:
- At build: pre-render all posts from
generateStaticParams. - First request after deploy: serve static.
- Within 1 hour: serve static (cached).
- After 1 hour: serve stale → trigger background regeneration → next request gets fresh.
On-demand revalidation (from a Server Action):
import { revalidatePath, revalidateTag } from "next/cache";
async function updatePost(slug: string) {
await db.posts.update({ where: { slug }, data: { ... } });
revalidatePath(`/blog/${slug}`); // invalidate this path
revalidateTag("posts"); // or invalidate all tagged "posts"
}After mutation, immediately purge the cached page. Next request triggers regeneration.
fetch cache directives.
Next.js wraps fetch with caching:
// Cache forever
const data = await fetch(url, { cache: "force-cache" });
// Never cache
const data = await fetch(url, { cache: "no-store" });
// Revalidate every 60 seconds
const data = await fetch(url, { next: { revalidate: 60 } });
// Tag for on-demand purging
const data = await fetch(url, { next: { tags: ["posts", "user-123"] } });revalidateTag("posts") purges all fetch calls tagged "posts". Combine with revalidatePath for finer control.
Next 15 change: default cache: is now "no-store" (was "force-cache"). Less surprising; matches expectations. Older code may need cache: "force-cache" added explicitly.
Edge runtime — when use?
Edge runtime uses V8 isolates (like Cloudflare Workers, Vercel Edge Functions) instead of Node.js:
export const runtime = "edge";
export default async function Page() {
// ...
}Pros:
- Faster cold starts (~5ms vs 100-500ms for Node serverless).
- Closer to user (deployed to many edge locations).
- Lower TTFB for global users.
Cons:
- Limited APIs — no
fs, no Node native modules, restricted timers, smaller heap. - No long-running tasks (50ms CPU limit on Vercel by default).
- No
process.envfor build-time secrets (uses different env model). - Smaller ecosystem of compatible libraries.
Use for: read-heavy routes, simple SSR, geolocation-based personalization, header-based redirects. Don’t use for: routes needing DB drivers (most are Node-only), heavy compute, large dependencies.
Partial Prerendering (PPR) — what is it?
A single route can have a static shell + dynamic holes. The shell streams from cache immediately; the dynamic parts stream when they’re ready. It started as an experimental flag (experimental_ppr, Next 14-15); Next.js 16 (October 2025) ships it as Cache Components: enable cacheComponents: true in next.config, mark cacheable work with the "use cache" directive, and wrap per-request parts in <Suspense>.
// next.config.ts: { cacheComponents: true }
export default function Dashboard() {
return (
<>
<Header /> {/* static */}
<Sidebar /> {/* static */}
<Suspense fallback={<Loading />}>
<UserStats /> {/* dynamic — uses cookies() */}
</Suspense>
</>
);
}Behavior:
- The page shell is pre-rendered at build time (Header, Sidebar).
- The
<UserStats>boundary is rendered per-request. - Response: static shell streams from CDN instantly + dynamic chunk streams when ready.
Best of both worlds: ISR-grade serve speed for the static parts + fresh per-request data for the dynamic parts. Two more Next.js 16 changes to know: Turbopack is the default bundler, and middleware.ts was renamed proxy.ts.
Caching layers — what’s in Next.js?
Multiple:
- Request memoization — same
fetchcall within one render dedupes. - Data Cache (
fetchcache) — Next stores fetch results across requests (default:no-store; opt-in toforce-cacheorrevalidate). - Full Route Cache — entire rendered route cached per (route, search params).
- Router Cache — client-side cache of prefetched routes (for instant navigation).
- CDN cache — your hosting provider’s CDN (Vercel automatic).
Each layer can be controlled / purged independently. Most teams need to think about the Data Cache (fetch directives) and Router Cache (<Link prefetch>). The Full Route Cache is mostly automatic.
Static + dynamic in the same route.
A layout can be static while a page is dynamic; a page can have static parts + dynamic Suspense boundaries (PPR or streaming SSR).
// app/(dashboard)/layout.tsx — static
export default function Layout({ children }) {
return <div className="layout"><Nav />{children}</div>;
}
// app/(dashboard)/page.tsx — dynamic
export const dynamic = "force-dynamic";
export default async function Page() {
const data = await fetchPersonalized();
return <Dashboard data={data} />;
}The shell stays static; the page renders per-request. Useful for “marketing-style chrome + per-user content.”
How do you debug “why is this route dynamic”?
Run next build and watch the output:
Route Size First Load JS
○ / 12 KB 100 KB
ƒ /dashboard 8 KB 95 KB
● /blog/[slug] 5 KB 90 KBSymbols:
○static (pre-rendered at build).ƒdynamic (rendered per-request).●SSG (static at build withgenerateStaticParams).◐partially pre-rendered (PPR).
If a route you expected to be static shows ƒ, something triggered dynamic. Common culprits: cookies() in a deep import, searchParams use, fetch(..., { cache: "no-store" }). Trace from the build error or use next build --debug.
When NOT to use Next.js’s caching?
- Mutation-heavy APIs — every response is unique; caching wastes effort.
- Real-time feeds — sub-second freshness needed; ISR’s “60-second stale” isn’t acceptable.
- Per-user data where the user changes frequently — cache by user ID becomes the cache, not the URL.
Default to cache: "no-store" (Next 15 default) for these. Opt into caching deliberately, not by accident.
Gotchas
- Mixing
force-staticwithcookies()— build error. The static route can’t use per-request APIs. searchParamsmakes the route dynamic — any page with?parameters can’t be statically pre-rendered (unless you list them ingenerateStaticParams).fetchcache key includes the URL + method + body — different bodies = different cache entries.- Cache stampede on revalidate — first request after revalidation triggers regen; concurrent requests during regen get stale (Next’s default) or all wait (depends on platform).
revalidatePathinvalidates the route cache, not the fetch cache — pair withrevalidateTagfor fetch invalidation.- Edge runtime + database driver — most drivers are Node-only. Use HTTP-based clients (Vercel Postgres, Drizzle’s HTTP adapter, Cloudflare D1).
- Build time + many pages —
generateStaticParamsreturning 100K paths = 100K builds. Use ISR +dynamicParams: trueto generate on-demand.
Related
Further reading
- Next.js — Caching: https://nextjs.org/docs/app/building-your-application/caching
- Next.js — Route Segment Config: https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config
- Next.js — Edge Runtime: https://nextjs.org/docs/app/api-reference/edge
- Partial Prerendering: https://nextjs.org/docs/app/api-reference/next-config-js/ppr
Interview angle 5
- “How do you control rendering per route?” - exported route constants:
dynamicto force static or dynamic,revalidate = Nfor ISR,runtimeto choose Node or Edge. It is configuration in the route file rather than a global build mode, which is what makes a hybrid application practical. - “How does fetch caching work in Next 15?” - explicitly.
cache: "no-store"is the default,cache: "force-cache"opts in, andnext: { revalidate, tags }sets the window and the purge key. On-demand invalidation isrevalidatePathorrevalidateTag, typically called from a Server Action after a mutation.- Follow-up: “Why did the default change?” - because caching by default surprised people into shipping stale data, and a caching layer you did not know was on is worse than one you have to enable.
- “A route you expected to be static compiled as dynamic. How do you find
out why?” - the build output symbols: static, dynamic, SSG and PPR each get
a marker per route. Then look for what opted it out - reading
cookies()orheaders(), or an uncachedfetch- because one such call makes the whole route dynamic. - “What is Partial Prerendering trying to solve?” - the all-or-nothing choice. A static shell is served instantly from the CDN with dynamic holes streamed in, so one personalised widget no longer forces the entire page to be rendered per request. Still experimental, so check its status before depending on it.
- “How many caches are involved?” - five, and knowing that is the answer:
per-request memoisation, the Data Cache for
fetch, the Full Route Cache, the client Router Cache, and the CDN. Each is separately controllable, and most “why is this stale” incidents are a disagreement between two of them.