The Inbox Problem: HTML That Cannot Exist Until the Request Arrives
Your task is a mailbox page: it shows "you have 3 unread messages" — your messages, from your session. There is no build-time trick that can produce this HTML. The value depends on who is asking, and the request is the only place that information exists. This is the case dynamic rendering answers: the page's HTML is built on the server during the request, from data that request alone provides.
Follow one request through the machinery, because every rendering-strategy question in an interview reduces to "when was the HTML built?" The browser's request hits the Next.js server (Node runtime by default), the matched route's components run as Server Components, their awaits resolve against your database, React renders the tree to an HTML string, and that is what goes on the wire — followed later by the JavaScript needed to hydrate the interactive parts. Nothing was cached ahead of time; nothing will be reused for the next visitor.
Contrast the two timelines for the same route:
| Prerendered (static) page | Dynamic (per-request) page | |
|---|---|---|
| HTML built | at build / regeneration time | on every request |
| Data freshness | as of the last build | always current |
| "Unread: 3" for the right user | impossible | the point |
| TTFB | ~1–20 ms (file/CDN hit) | data fetch + render, typically 100–800 ms |
| Capacity | CDN serves it; origin does ~0 work | every request costs CPU on your servers |
That last row is the trade-off people forget. A render that burns 150 ms of CPU on one core caps a single instance at roughly 6–7 requests per second; serving 200 RPS of the same page means ~30 instances (or aggressive caching of the parts that can be cached). Dynamic rendering is not "free freshness" — it is freshness paid for on every single request, forever. When an interviewer asks "why not make everything dynamic?", that capacity math is the answer, and the escape hatches — caching data, static segments, Static Generation with generateStaticParams — are what this whole phase is about.
The Pages Router solved this same problem with a page-level function:
// Pages Router (legacy): pages/mailbox.tsx
export async function getServerSideProps(context: GetServerSidePropsContext) {
const session = await getSession(context.req);
const unread = await countUnread(session.userId);
return { props: { unread } }; // fetched BEFORE the component renders
}
The App Router deletes the special function. The component itself is async and fetches during render:
// app/mailbox/page.tsx — Server Component by default
import { cookies } from "next/headers";
import { countUnread } from "@/lib/mail";
export default async function MailboxPage() {
const token = (await cookies()).get("session")?.value;
const userId = await verifySession(token);
const unread = await countUnread(userId); // direct DB access, no API layer
return <MailboxView unread={unread} userId={userId} />;
}
Same request-time execution, fewer moving parts: no props serialization boundary, no getServerSideProps ordering rules, and the query runs in the same process as the render instead of a round trip through an HTTP endpoint. If the concept of async server data access is new to you, Server Components by Default is where that model is established; this topic is about when that render happens, not where the component runs.
One important non-goal: dynamic rendering does not mean "the page is JavaScript." The HTML you send is complete server-rendered markup — the mailbox reads fine with JS disabled (interactivity hydrates on top). Dynamic describes the timing of the build, not the side it runs on. Server versus client, and static versus dynamic, are two independent axes, and mixing them up is the most common conceptual error in this area.
Why Usable HTML First Beats JavaScript First — And What It Costs
Two pages can show identical UI. One sends finished HTML and hydrates; the other sends an empty <div id="root"> plus 250 KB of JavaScript that fetches data, then renders. On a mid-range phone over 4G, the first shows readable text in 300–600 ms; the second shows a blank screen for 2–4 seconds while JS downloads, parses, executes, and its useEffect fetches complete. Server rendering sends usable markup before JavaScript, which is why first contentful paint — and everything gated on it — improves.
The mechanism behind that advantage is the browser's preload scanner. The moment the HTML arrives, the scanner discovers <img>, fonts, and stylesheet links without waiting for the main thread — so the hero image's download starts in parallel with JS execution instead of after it. That is also why the Largest Contentful Paint element usually wins under SSR: its URL is in the first packet, not the fifth JavaScript chunk. In a single-page app, the LCP image URL often does not exist until React runs, which forfeits 1–2 seconds on mobile before the image even enters the request queue.
Be precise about what "hydrates" means in the App Router, because the old model no longer fully applies. Yes, interactive pieces still need their JavaScript and a client-side mount. But with Server Components, the components that produced the static markup ship zero of their code — the browser receives HTML plus the serialized RSC payload, not a duplicate of the render logic. Dynamic SSR pages therefore weigh less on the client than Pages Router SSR pages of the same UI, where every component downloaded twice: once to render on the server, once to hydrate in the browser. Measured first-load JS is the number to quote; next build prints it per route.
The cost ledger must be kept honest, because SSR's weaknesses are real and interviewers probe them:
| Factor | Per-request server rendering | Trade-off |
|---|---|---|
| TTFB | waits on data fetch + render | 100–800 ms vs ~10 ms for a cached page |
| Capacity | every visit burns CPU | scales with traffic, not with cache hit rate |
| Availability | origin down → pages down | CDN-cached pages survive origin outages |
| Locality | HTML built near the DB | far-away edge PoPs still round-trip to origin |
| Crawlability | full HTML per crawl | bot traffic now costs real compute |
The availability row deserves emphasis. A prerendered page lives in a CDN; if your database hiccups, the cached copy keeps serving. A dynamic page has no such buffer — every request needs a healthy render path. That is why teams put static shells, stale-while-revalidate data caches, and rate limits in front of dynamic routes rather than letting raw queries face the internet.
SEO benefits from the same property, with a caveat. Crawlers receive the final HTML immediately — no render queue, no budget spent executing your bundle. Google can execute JavaScript, but link previews and many smaller crawlers cannot, and Googlebot's second-wave JS rendering is not guaranteed within a crawl window. For anything you need indexed reliably, server-rendered HTML is the safe default. The other half of the SEO story — titles, descriptions, social cards — is The Metadata API, which rides on the same server render.
So when is per-request rendering the right answer? When the data is per-user, when it changes on every write and staleness is unacceptable (live pricing, seat availability), or when the page has no meaningful key to cache by. When the answer is "per-user only sometimes," the interesting designs split the page — static shell, dynamic islands — which is where Forcing Dynamic Rendering and streaming come in.
The Accidental Dynamic Route: How One cookies() Call Undoes Your Static Build
You prerender a marketing page. It works. Then a teammate adds a personalized greeting to the shared layout — const name = (await cookies()).get("user") — and suddenly a route you believed was static is rebuilt on every request. No warning, no error; you discover it when the server bill doubles. This is the defining behavior of the App Router's rendering model: reading a request-time API anywhere in a route's tree opts that route into dynamic rendering, automatically.
The opt-in list, in full:
| API | Where | Effect |
|---|---|---|
cookies() |
next/headers, any server component |
route becomes dynamic |
headers() |
next/headers |
route becomes dynamic |
awaiting searchParams |
page props | that page is dynamic (the route cannot be known without the query string) |
fetch(..., { cache: "no-store" }) or next: { revalidate: 0 } |
data layer | fetched data is per-request |
connection() |
next/server |
explicit "this needs the request connection" signal |
The trigger propagates upward in the only direction that matters for surprises: a dynamic API used in a layout makes every page under that layout dynamic, because the layout's HTML must be rebuilt per request to compose the page. A dynamic API confined to one page's leaf component affects just that page. This is why the auth-layout pattern — reading the session cookie in app/(app)/layout.tsx to render the avatar — quietly dynamizes entire sections, and why the fix (splitting session lookup into a client component, or accepting dynamic rendering consciously) is a design decision rather than a bug fix.
The build output is where you find out what actually happened. Read the route table after every next build:
Route (app) Size First Load JS
┌ ○ / 1.2 kB 98.4 kB
├ ○ /pricing 0.9 kB 97.2 kB
├ ƒ /dashboard 2.4 kB 101.3 kB
├ ƒ /mailbox 1.8 kB 100.7 kB
└ ● /blog/[slug] 1.1 kB 98.1 kB
○ means statically prerendered (HTML generated at build, served from cache — the subject of the next topic), ƒ means rendered on demand per request, ● means some paths were prerendered via generateStaticParams with the rest rendered on demand. Seeing ƒ next to a route you expected to be ○ is the signal: trace which API in its tree requested per-request rendering. On the Pages Router the same table used λ for serverless-rendered pages — if a codebase you are interviewing about still shows λ, it predates the App Router.
You can also force the state explicitly rather than inherit it, with a segment config export:
export const dynamic = "force-dynamic"; // always render this segment per request
The two directions exist for different reasons. Implicit opt-in protects correctness — it is never safe to serve cookie-derived HTML from cache, so the router dynamizes by default. Explicit force-dynamic covers the cases the API scan cannot see: data you read through a non-fetch path (a direct Prisma query with time-dependent results) that must not be prerendered even though no request API touched it. The full option set, and how static and dynamic segments coexist in one tree, is Forcing Dynamic Rendering.
A last calibration for interviews: "server-side rendering" as a term covers both timing regimes — this topic's per-request rendering and build-time prerendering are both server rendering. The App Router's default is to prerender when it can and go dynamic when an API demands it. Your job as the engineer is to know which one each route landed on, verify it in the build table, and defend that choice with the freshness-versus-capacity math from the first chapter — not to treat the distinction as trivia. The next topic, Static Generation with generateStaticParams, is what happens when you choose the other side on purpose.
Quiz
1. In the App Router, which action automatically switches a route from prerendered to dynamic rendering?
2. A shared layout reads the session cookie to show an avatar. What happens to the static marketing page rendered inside it?
3. next build prints `ƒ` next to /dashboard and `○` next to /pricing. What does that mean?
4. What is the main capacity cost of per-request server rendering?
Flashcards
Question
What is dynamic (per-request) rendering in the App Router?
Click to reveal answer
Answer
The route's HTML is built on the server during each request, from data that request provides (session, search params, live DB state). Triggered automatically by cookies(), headers(), awaited searchParams, no-store fetches, or connection(); forced with export const dynamic = 'force-dynamic'.
Question
Why does server-rendered HTML improve first contentful paint and LCP compared with a client-rendered SPA?
Click to reveal answer
Answer
The browser paints from the first packet and the preload scanner discovers images/fonts in that HTML immediately, so the LCP image download starts before JS finishes — versus a blank shell that must download, parse, and execute JS, then fetch data, before any content exists.
Question
How does the App Router's per-request data fetching differ from Pages Router getServerSideProps?
Click to reveal answer
Answer
getServerSideProps ran as a page-level function before render and passed serialized props into the component. The App Router has no such function: the Server Component is itself async and awaits data during render — same request-time timing, no props boundary, direct DB access, and no API-style round trip.
Question
What do the symbols in the App Router build route table mean?
Click to reveal answer
Answer
○ = statically prerendered at build (served from cache); ƒ = dynamically rendered per request; ● = some paths prerendered via generateStaticParams, the rest on demand. The Pages Router table used λ for serverless-rendered pages. The table is the authoritative check of what a route actually became.
Question
Name the trade-off ledger of per-request rendering in one line each.
Click to reveal answer
Answer
Freshness: always current data. Markup: complete, crawlable HTML before JS. Cost: TTFB 100–800 ms and one CPU render per visit — capacity scales with instances, not cache hits. Resilience: origin or DB down means pages down, since nothing buffered serves.
Revision Notes
Key Takeaways
- 1. Dynamic rendering builds HTML per request — the only option when output depends on who is asking
- 2. Server-rendered markup paints before JavaScript and lets the preload scanner start the LCP image early; App Router adds zero client code for Server Components
- 3. cookies(), headers(), awaited searchParams, and no-store fetches opt routes into dynamic rendering — in a layout, for the entire subtree
- 4. The build route table (○/ƒ/●) is ground truth for what each route became; read it after every structural change
- 5. Per-request rendering trades capacity and origin resilience for freshness — quote the CPU-per-render math instead of calling it free
Interview Tips
- • "When would you use per-request rendering?" — per-user output, write-then-read freshness, or pages with no cache key; then show you know the cost (CPU per visit, no CDN buffer) and the mitigation (cache data, split static/dynamic segments)
- • "Why is my static route dynamic?" — walk the tree: a request-time API in the page or an ancestor layout; the build table shows ƒ; this answer demonstrates you debug with the router's own signals
- • "SSR vs CSR" — SSR sends paintable HTML first (better FCP/LCP on mobile, crawlable), CSR ships a shell; the honest add is that SSR moves the latency to TTFB and the failure mode to origin availability
- • "App Router vs Pages Router server rendering" — no getServerSideProps: components are async and fetch during render; mention params/searchParams being Promises and the removal of the cached-by-default GET behavior across versions
Cheat Sheet
Server Rendering per Request
When was the HTML built? Every rendering question reduces to this.
| Strategy | HTML built | Freshness | TTFB | Origin cost |
|---|---|---|---|---|
| Static (SSG) | build time | stale until rebuild | ~1–20 ms | ~0 (CDN) |
| ISR | build + background refresh | up to revalidate old |
~1–20 ms | occasional regen |
| Dynamic (SSR) | every request | always current | 100–800 ms | every visit |
| Streaming SSR | shell now, parts later | current | shell ms | per request |
Automatic dynamic opt-ins: cookies() · headers() · awaited searchParams · fetch with cache: "no-store" / next: { revalidate: 0 } · connection(). A trigger in a layout dynamizes every page beneath it. Force explicitly: export const dynamic = "force-dynamic".
Verify, never assume: read the next build route table — ○ static, ƒ dynamic, ● partial (Pages Router: λ). Check it after touching any layout.
App Router vs Pages Router: no getServerSideProps; the Server Component itself is async and fetches during render — direct DB access, no props serialization, same request-time semantics.
Cost math to quote: a 150 ms CPU render ≈ 6–7 req/s per core → 200 RPS needs ~30 instances unless cached. Dynamic pages lose the CDN-outage buffer that static pages have.
Not the same axes: where it runs (server vs client component) is independent of when it renders (build / cached / per-request).