Next.js Deep Dive — 02 · Rendering Strategies & Runtimes
"Is this page static or dynamic?" used to be a per-route yes/no question in Next.js, answered implicitly by whatever APIs the route happened to touch. One cookies() call in a shared header could turn a whole marketing site into per-request rendering, and nothing warned you. Next.js 16's Cache Components turn the question into a per-component one: every route gets a prerendered static shell, and only the parts that genuinely need request data stream in afterwards. This post works through the whole rendering spectrum: how the classic static/dynamic decision is made, streaming with Suspense and what it does to status codes, ISR and generateStaticParams, Partial Prerendering and its Cache Components form, proxy.ts (formerly middleware.ts), the Node.js versus Edge runtime decision, and the hard limits of output: 'export', using this blog, a Next.js 14 static export, as the worked example.
Series map: 00 App Router Mental Model → 01 Data Fetching, Caching & Server Actions → 02 Rendering Strategies & Runtimes (this post) → 03 Production Next.js (future: see the extensions table in 03).
Learning Outcome
- Predict whether a route is prerendered or rendered per request under the pre-16 model, and read the
next buildroute table. - Use Suspense and
loading.tsxto stream, and explain why a streamednotFound()returns HTTP 200. - Configure ISR with time-based and on-demand revalidation, and know where the ISR cache lives when self-hosting.
- Explain Partial Prerendering and how Cache Components builds a static shell with dynamic holes.
- Fix the three Cache Components build errors: runtime data outside Suspense, uncached data outside Suspense, and synchronous IO during prerender.
- Write
generateStaticParamsfor nested and catch-all segments, and choose how unlisted params behave. - Write a correct
proxy.tsmatcher, and know what Proxy must not be used for. - Choose between the Node.js and Edge runtimes in 2026, given that Edge is deprecated for routes.
- List what a static export cannot do, and why.
Chapter 1 — Static vs dynamic, the classic model
Terminology
- Prerendering — rendering a route at build time (or at revalidation time) and storing the HTML and RSC payload for reuse.
- Dynamic rendering — rendering a route per request.
- Request-time APIs (earlier docs: "dynamic APIs") — APIs whose value depends on the incoming request:
cookies(),headers(),searchParams,connection(),draftMode(). - Static shell — the prerendered part of a route that can be sent before any request-specific work runs (Cache Components term).
- Dynamic hole — a Suspense boundary whose content is rendered at request time and streamed into the shell.
How a route became static or dynamic (14 and 15)
Without Cache Components, next build tries to prerender every route. A route switches to dynamic rendering for the whole route when, anywhere in its tree, it:
- calls a request-time API (
cookies(),headers(),searchParams,connection(),draftMode()), - runs a
fetchwithcache: 'no-store'orrevalidate: 0(or callsunstable_noStore()), - or sets
export const dynamic = 'force-dynamic'.
The route segment config can force the decision:
| Export | Effect |
|---|---|
dynamic = 'auto' |
Default: decided by what the route uses |
dynamic = 'force-dynamic' |
Always per request (in 15 also sets no-store on fetch) |
dynamic = 'force-static' |
Prerender; request-time APIs return empty values |
dynamic = 'error' |
Prerender, and fail the build if anything dynamic is used |
revalidate = n |
ISR interval in seconds for the route |
dynamicParams = false |
Params not returned by generateStaticParams return 404 |
next build prints a route table with a symbol per route: ○ static, ● prerendered with generateStaticParams (SSG), ƒ dynamic. Reading it after every significant change is the cheapest regression test for rendering mode.
Response headers follow the decision when self-hosting: dynamic pages get Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate; ISR pages get s-maxage=<revalidate>, stale-while-revalidate; hashed static assets get public, max-age=31536000, immutable.
Common pitfall: reading
cookies()in the root layout (for a theme or locale) makes every route dynamic, because the layout is in every route's tree. Read it in a small Suspense-wrapped component instead, or set the attribute before paint with an inline script.
Chapter 2 — Streaming with Suspense
What streaming does
The App Router streams HTML. React sends the shell first with Suspense fallbacks in place, keeps the connection open, and later sends each boundary's content with a small inline script that swaps it in. The same happens for the RSC payload on client navigations. The user sees useful content as soon as the fastest part is ready instead of when the slowest is.
loading.tsx is a shortcut: it wraps the segment's page in a Suspense boundary with the file's component as fallback. For finer control, place <Suspense> yourself around each slow region:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
export default function Page() {
return (
<main>
<ProductHeader /> {/* fast, in the shell */}
<Suspense fallback={<PriceSkeleton />}>
<LivePrice /> {/* streams when ready */}
</Suspense>
<Suspense fallback={<ReviewsSkeleton />}>
<Reviews /> {/* streams independently */}
</Suspense>
</main>
)
}
Push dynamic access down. The smaller the component that awaits slow or request-bound data, the more of the page lands in the first flush.
Status codes after the first byte
Once the first chunk is sent, the HTTP status line and headers are gone. If notFound(), redirect(), forbidden() or unauthorized() runs inside a boundary that already streamed, the response status stays 200. Next.js handles the UI (and inserts a noindex robots meta tag for a streamed not-found), but monitoring that counts 404s by status code will miss them.
Trick: call
notFound()for a missing resource before any Suspense boundary (at the top of the page, after the lookup), so the real 404 status is sent. Only data that is allowed to fail softly belongs inside the streamed parts.
Streaming needs every hop to cooperate
Buffering anywhere between the server and the browser collapses a stream into one late response. Self-hosted, disable Nginx buffering (Next.js docs suggest the X-Accel-Buffering: no header) and check that load balancers support chunked transfer or HTTP/2 streaming. Some cloud load balancers buffer by default.
Chapter 3 — ISR and generateStaticParams
Incremental Static Regeneration
ISR is stale-while-revalidate for whole routes: the prerendered page is served; once it is older than the revalidate interval, the next request still gets the stale page while a regeneration runs in the background; the request after that gets the new page.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// app/blog/[slug]/page.tsx (14/15 model)
export const revalidate = 3600 // at most one regeneration per hour per path
export async function generateStaticParams() {
const posts = await getAllPosts()
return posts.slice(0, 100).map(p => ({ slug: p.slug })) // prerender the top 100
}
export default async function Page({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params
const post = await getPost(slug)
if (!post) notFound()
return <Article post={post} />
}
Paths not returned by generateStaticParams are rendered on first request and then cached (the default dynamicParams = true), or return 404 with dynamicParams = false. On-demand ISR is revalidatePath / revalidateTag from Post 01.
Operational facts:
- The ISR cache is on the local file system of each server instance by default (plus a 50 MB in-memory layer). Two pods have two caches; a custom
cacheHandlerfixes it (Post 03). expireTime(Next.js 15) sets thestale-while-revalidatewindow in theCache-Controlheader; its default is one year so CDNs can apply SWR properly.- The Edge runtime does not support ISR.
generateStaticParams in detail
- It returns an array of objects whose keys are the dynamic segment names and whose values are strings (or string arrays for catch-alls:
{ slug: ['a', 'b'] }for[...slug]). - For nested dynamic segments, a child's
generateStaticParamsreceives the parent'sparams, or a single function at the deepest level can return all segments at once. - It runs at build time, before the pages it lists are rendered.
This blog's post route is app/[year]/[month]/[day]/[slug]/page.tsx. Its generateStaticParams returns one object with all four segments per post from getAllPostPaths(), and the paginated index at app/page/[n]/page.tsx sets dynamicParams = false, so /page/999/ is a 404 rather than a server render the static host could never perform.
ISR under Cache Components (16)
With cacheComponents, two rules change:
generateStaticParamsmust return at least one param. Returning[]raisesempty-generate-static-params, because Next.js needs one real render to validate the static shell. Unlisted paths still work: they get the shell at once and stream the rest.dynamicParamsis not allowed. To reject unknown params, callnotFound()when the lookup fails.
Next.js 16.3 improved this further: a path you did not prerender serves an instant loading shell to its first visitor, then is upgraded to the fully prerendered page in the background, and later visitors get the final content from the cache.
Chapter 4 — Partial Prerendering and Cache Components
From "static or dynamic" to "both, per component"
Partial Prerendering (PPR) was introduced as an experiment in Next.js 14 (2023), offered as an incremental opt-in (experimental_ppr per route) in 15, and folded into Cache Components in 16, which removed the experimental.ppr flag. With cacheComponents: true, PPR is simply how the App Router renders.
The model: at build time Next.js renders every route as far as it can. Everything that is static or cached ('use cache') becomes the static shell. Every Suspense boundary whose content needs request data or uncached data becomes a dynamic hole. At request time, the shell is sent immediately (it can come from a CDN) and the holes are rendered and streamed into the same response.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// app/product/[id]/page.tsx (Next.js 16, cacheComponents: true)
import { Suspense } from 'react'
import { cookies } from 'next/headers'
import { cacheLife } from 'next/cache'
export async function generateStaticParams() {
return [{ id: 'p-100' }] // at least one
}
export default function Page({ params }: { params: Promise<{ id: string }> }) {
return (
<>
<Suspense fallback={<ProductSkeleton />}>
<ProductInfo params={params} /> {/* cached: part of the shell for known ids */}
</Suspense>
<Suspense fallback={<CartSkeleton />}>
<CartBadge /> {/* reads cookies: dynamic hole */}
</Suspense>
<Suspense fallback={<StockSkeleton />}>
<LiveStock params={params} /> {/* uncached query: dynamic hole */}
</Suspense>
</>
)
}
async function ProductInfo({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
return <ProductView product={await getProduct(id)} />
}
async function getProduct(id: string) {
'use cache'
cacheLife('hours')
return db.product.findUnique({ where: { id } })
}
async function CartBadge() {
const cartId = (await cookies()).get('cart')?.value
return <span>{cartId ? await countItems(cartId) : 0}</span>
}
The three errors you will meet
With Cache Components, Next.js validates every route in development and at build time. Every piece of data must be one of: cached ('use cache'), inside a Suspense boundary (a dynamic hole), or static. The errors map to those rules:
| Symptom | Cause | Fix |
|---|---|---|
Blocking-route insight / error on cookies(), headers(), searchParams |
Runtime data read outside any Suspense boundary | Move the read into a Suspense-wrapped child; pass the params / searchParams promise down and await it there |
| Uncached data accessed outside Suspense | An uncached fetch or DB query at the top of a page or layout |
Add 'use cache' near the data access, or wrap the component in Suspense |
| Synchronous IO during prerender | new Date(), Date.now(), Math.random(), crypto.randomUUID() in the shell |
Wrap the part in Suspense and await connection() first, or compute it in a Client Component |
Two more rules from the 16 migration guide:
- Client hooks that read the route (
usePathname,useParams,useSelectedLayoutSegment(s)) suspend when the route has unknown dynamic params, anduseSearchParamsalways needs a Suspense boundary. A breadcrumb in a shared layout is the classic offender. - Reading a cookie to set
<html lang>ordata-themein the root layout makes the whole tree request-bound. Next.js recommends an inline<head>script that sets the attribute before paint instead.
As of 16.3, a segment can export instant = false to defer validation while you migrate incrementally. It does not clear synchronous-IO errors.
Watch out: a build that passes can still fail at request time. Calling
cookies()through a helper inside a'use cache'function on a dynamically rendered route surfaces only when the route runs undernext start. Exercise dynamic routes in a production build before shipping.
Chapter 5 — Proxy (formerly Middleware)
The rename and what it signals
Next.js 16 renamed middleware.ts to proxy.ts and the exported function to proxy (codemod: npx @next/codemod@canary middleware-to-proxy .). The reasons given: "middleware" invited Express-style misuse, and "proxy" names what the feature is, a network boundary in front of the app. Next.js recommends using it as a last resort.
| Version | Runtime of middleware / proxy |
|---|---|
| 12–15.1 | Edge runtime only |
| 15.2 | Node.js runtime experimental |
| 15.5 | Node.js runtime stable |
| 16.0 | proxy.ts runs on Node.js and the runtime option cannot be set (it throws). middleware.ts still works for Edge but is deprecated. |
Writing it correctly
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// proxy.ts (project root, next to app/)
import { NextResponse, type NextRequest } from 'next/server'
export function proxy(request: NextRequest) {
const hasSession = request.cookies.has('session')
if (!hasSession && request.nextUrl.pathname.startsWith('/dashboard')) {
return NextResponse.redirect(new URL('/login', request.url)) // optimistic check only
}
const headers = new Headers(request.headers)
headers.set('x-request-id', crypto.randomUUID())
return NextResponse.next({ request: { headers } }) // to the app, not to the client
}
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)'],
}
Rules worth memorising:
- Without a
matcher, Proxy runs on every request, including_next/static,_next/imageandpublic/files. An auth redirect without a negative matcher blocks CSS and JS. matchervalues must be constants; they are statically analysed at build time and variables are ignored.- Execution order:
headersfromnext.config,redirectsfromnext.config, Proxy,beforeFilesrewrites, file-system routes,afterFilesrewrites, dynamic routes,fallbackrewrites. NextResponse.next({ request: { headers } })passes headers upstream to the app;NextResponse.next({ headers })sends them to the client. Confusing the two leaks internal headers.- Flight headers (
rsc,next-router-state-tree,next-router-prefetch) are stripped fromrequest.headersin Proxy so RSC and HTML requests are handled alike. fetchcache options (cache,next.revalidate,next.tags) have no effect in Proxy.event.waitUntil(promise)(second argument,NextFetchEvent) keeps the invocation alive for logging after the response.- Since 15.1,
unstable_doesProxyMatchfromnext/experimental/testing/serverunit-tests matchers.
What Proxy is not
Next.js docs are explicit: Proxy is for optimistic checks (is there a session cookie at all?) and redirects/rewrites, not a full session-management or authorisation solution, and not for slow data fetching. Two structural reasons:
- Server Functions are POSTs to the page route. A matcher that excludes a path also skips every action on it, and moving an action to another route silently changes coverage.
- A bypass is total. In March 2025, CVE-2025-29927 let an attacker skip middleware entirely by sending a crafted
x-middleware-subrequestheader on self-hosted deployments. Any app whose only auth check lived in middleware was open. Post 03 covers the incident.
Trick: treat Proxy as a performance optimisation for the happy path (redirect obviously logged-out users early), and put the real check in the Data Access Layer that every page, action and Route Handler goes through.
Chapter 6 — Node.js vs Edge runtime
The Edge runtime is a restricted, Web-API-only environment (no fs, no native Node.js modules, no require, no eval or new Function, no ISR) designed to start fast and run close to users. The Node.js runtime has the full Node.js API and is the default.
By 2026 the balance has moved decisively to Node.js:
proxy.tsruns only on Node.js (16.0).- Cache Components requires the Node.js runtime (16.0).
- The route segment option
export const runtime = 'edge'is marked deprecated in the docs (as of 16.3), with the advice to remove it; the error pageedge-runtime-deprecatedsays the Node.js runtime needs no replacement. - Next.js 16.3 replaced web streams with native Node.js streams in the App Router rendering layer and reports up to 22% more requests handled under load.
| Concern | Node.js runtime | Edge runtime |
|---|---|---|
| APIs | Full Node.js | Web APIs subset + AsyncLocalStorage polyfill |
| npm packages | All | ESM, no native Node.js APIs |
| ISR / Cache Components | Yes | No |
| Status in 16 | Default everywhere | runtime = 'edge' deprecated; middleware.ts (Edge) deprecated |
| Where the latency goes | Near your database | Near the user, far from the database |
The latency row is the one people forget: a function at the edge that makes three sequential queries to a database in one region pays the long round trip three times. Rendering next to the data and caching the output at the CDN usually wins.
Chapter 7 — Static export and its limits
What output: 'export' produces
output: 'export' makes next build write plain files to out/: one HTML file per route, the RSC payload per route for client navigation, and the static assets. Server Components run at build time, like classic static-site generation. Any static host can serve the result.
This blog is exactly that. Its next.config.mjs, reduced to the export branch:
1
2
3
4
5
6
7
8
const nextConfig = {
images: { unoptimized: true }, // no image optimiser at runtime
reactStrictMode: true,
trailingSlash: true, // /2026/09/14/slug/index.html
env: { NEXT_PUBLIC_BASE_PATH: '/blog' },
output: 'export', distDir: 'out', basePath: '/blog', assetPrefix: '/blog/',
}
It runs Next.js 14.2 with React 18.3, prerenders every post through generateStaticParams, and is served by GitHub Pages under /blog. basePath is why in-post image paths are rewritten at build time: a root-relative /assets/... link would 404 without the /blog prefix.
What it cannot do
From the Next.js static export guide, these need a server and are not supported:
- Dynamic routes with
dynamicParams: true, or withoutgenerateStaticParams() - Route Handlers that read the
Request(onlyGEThandlers that prerender, withdynamic = 'force-static') cookies(), Server Actions, Draft Mode, ISRrewrites,redirects,headersfromnext.config, and Proxy- Image Optimization with the default loader (use
unoptimizedor a customloader) - Intercepting Routes
'use cache' is also listed as not supported for static export. In next dev, using any of these on an export build errors as if dynamic = 'error' were set on the root layout.
A security side effect: an export has no Next.js server, so server-side vulnerabilities in the framework (the middleware bypass, the React Server Components remote code execution of December 2025) have nothing to attack. The trade is that every page must be knowable at build time and every "dynamic" feature becomes client-side JavaScript or a separate service.
Watch out:
NEXT_PUBLIC_values and theenvkey innext.configare inlined into the JavaScript at build time. That is fine for a base path; it is a leak for anything secret (Post 03).
Practice Exercises
Exercise 1 — Predict the rendering mode
In Next.js 15 without Cache Components, which of these routes are static? (a) a page that await fetch(url) with no options; (b) a page that reads searchParams; (c) a page with no data whose root layout reads cookies(); (d) a page with export const revalidate = 60.
Solution
(a) Static: prerendered at build, the fetch runs once. (b) Dynamic. (c) Dynamic: the layout is part of the route's tree. (d) Static with ISR every 60 seconds (unless something else makes it dynamic).
Exercise 2 — Real 404 while streaming
A product page streams its details inside Suspense and calls notFound() inside the streamed component when the product is missing. Crawlers see status 200. Restructure it.
Solution
Do a cheap existence lookup at the top of the page, before any Suspense boundary: const exists = await productExists(id); if (!exists) notFound(). Then render the Suspense-wrapped details. The 404 status is sent before streaming starts. With React.cache or 'use cache' on the lookup, the details component reuses the result.
Exercise 3 — Migrate to Cache Components
A Next.js 15 page has export const revalidate = 3600, fetches a product list, and reads cookies() for a currency. Migrate it for cacheComponents: true.
Solution
Remove revalidate. Put the product query in a function with 'use cache' and cacheLife('hours'). Move the cookie read into a <Suspense>-wrapped Prices component that reads the currency and passes it as an argument to a cached getPrices(currency). The list is in the shell; prices stream as a dynamic hole.
Exercise 4 — Fix the matcher
A proxy.ts redirects users without a session cookie to /login. After deploying, the login page renders unstyled and the logo is missing. Explain and fix.
Solution
With no matcher (or an over-broad one), Proxy also runs for _next/static, _next/image and public/ files, and redirects those requests to /login. Add a negative matcher that excludes _next/static, _next/image and static files, and exclude /login itself to avoid a redirect loop.
Exercise 5 — Runtime choice
A team wants export const runtime = 'edge' on a dashboard route that runs four Postgres queries per request, to "make it faster". Advise them for Next.js 16.3.
Solution
Do not. runtime = 'edge' is deprecated, incompatible with Cache Components, and puts the function far from a single-region database, so four sequential round trips get slower. Keep the Node.js runtime near the database, cache what can be cached with 'use cache', stream the rest, and let the CDN serve the static shell.
Exercise 6 — Static export audit
List which of these features would break if this blog added them: a search page reading ?q= on the server, an RSS feed as a GET Route Handler, a comment form using a Server Action, next/image with default settings.
Solution
Breaks: server-side ?q= (needs a request; do the search in a Client Component over a prebuilt index), Server Action (no server), next/image default loader (needs the optimiser; keep unoptimized or use a custom loader). Works: the RSS GET handler with dynamic = 'force-static', which is written to a file at build time.
Cheat-sheet
| Topic | Rule |
|---|---|
| Classic static → dynamic trigger | Request-time API, no-store fetch, force-dynamic anywhere in the tree |
| Build output symbols | ○ static, ● SSG with generateStaticParams, ƒ dynamic |
| Stream a region | <Suspense fallback> around the smallest slow component |
Streamed notFound() |
Status stays 200; check existence before Suspense |
| ISR (14/15) | export const revalidate = n + generateStaticParams |
| ISR cache (self-hosted) | Per-instance file system + 50 MB memory by default |
| Unlisted params | 14/15: dynamicParams; 16 + Cache Components: notFound() |
| Cache Components data rule | Cached, inside Suspense, or static |
new Date() in the shell |
Suspense + await connection(), or a Client Component |
generateStaticParams with Cache Components |
Return at least one param |
| Proxy file (16) | proxy.ts, function proxy, Node.js only |
| Proxy use | Optimistic checks, redirects, rewrites, headers |
| Upstream vs client headers | next({ request: { headers } }) vs next({ headers }) |
| Edge runtime | Deprecated for routes; not for Cache Components |
| Static export | No server features: actions, cookies, ISR, Proxy, rewrites, default image loader |
What's next
Post 03 covers production Next.js: authentication patterns that survive a Proxy bypass, the security incidents of 2025 and 2026 and what each one teaches, next/image and next/font, bundle analysis, self-hosting with output: 'standalone' and Docker versus Vercel, instrumentation.ts with OpenTelemetry, and environment-variable leaks through NEXT_PUBLIC_. For the streaming and Suspense internals on the React side, see the React Deep Dive series; Vue Deep Dive covers Nuxt's equivalent choices (hybrid rendering, route rules) for comparison.
References
- Next.js Docs — Caching and prerendering
- Next.js Docs —
cacheComponentsand Migrating to Cache Components - Next.js Docs — Incremental Static Regeneration and
generateStaticParams - Next.js Docs — Proxy and
proxy.jsreference - Next.js Docs — Edge Runtime and
runtimesegment config - Next.js Docs — Static Exports
- Next.js Docs — Self-hosting (streaming, caching)
- Next.js 16 and Next.js 16.3 release notes








