SSR & Server Components
How to choose between CSR, SSR, SSG, and ISR
CSR, SSR, SSG, and ISR are points in a three-axis space: data freshness × personalization × infra cost. Pick per route:
- Marketing site, docs. SSG: render once at build, serve from a CDN. Fastest and cheapest, and nothing on the page is per-user.
- News article, product page. ISR: the same page for everyone, but it changes. Serve the cached static page, re-render in the background after a revalidation window (or on-demand when the CMS or price updates). This is stale-while-revalidate at the page level. ❌ "SSG can't be fresh, so dynamic content needs SSR": ISR exists to break that dichotomy.
- Dashboard behind login, cart, feed. SSR per request, or plain CSR. A personalized page has a cache hit ratio near zero, so you pay a server render on every request, which makes sense only when the personalized content is what the user is waiting to see.
One line each on metrics (the deep dive is loading-performance): SSR and SSG move LCP earlier because real HTML paints before your bundle runs, while SSR can worsen TTFB, since the server renders before sending byte one. A single app mixes all of these, and when interviewers ask "which strategy?", they expect a table of routes.
Why hydration mismatches happen and how to fix them
SSR HTML is visible but dead: nothing has event listeners yet. Hydration renders the same tree on the client, walks the existing DOM, and attaches to it. ❌ "Hydration re-downloads/rebuilds the page": it reuses the server DOM. Reuse is why the contract is strict: the first client render must reproduce the server HTML. React attaches; it does not reconcile differences.
Everything in the mismatch family violates that one contract by rendering from inputs the two environments don't share:
new Date(),Math.random(): clock and randomness differ per run;toLocaleDateString(): server locale and timezone against the user's;typeof window !== "undefined"branches,localStorage/matchMediareads in render: the server takes one branch, the client the other. ❌ This guard is the documented cause of mismatches, and it is routinely mistaken for the SSR-safe pattern;- invalid nesting like
<p><div>: the browser rewrites the HTML before React sees it. Extensions that mutate the page do the same.
A mismatch is expensive. React throws away the server DOM (the root, or the nearest Suspense boundary) and re-renders it client-side. You paid for SSR, then discarded it, plus the user sees a flicker.
Fixes, in order of preference:
- Make the render deterministic. Pass server-fetched data down as props; render from shared inputs.
- Two-pass render for client-only data. The first client render shows the server-renderable state, then
useEffectflips anisClientflag and reads the browser API. Effects never run on the server, which is why this works. For subscription-shaped state (online status, viewport, storage),useSyncExternalStorewith agetServerSnapshotis the principled version: both first renders use the server snapshot, and the subscription updates after. suppressHydrationWarning. On exactly the one element whose text legitimately differs (a timestamp). It reaches one level deep and silences the warning; the DOM difference stays. ❌ At the root it hides every future mismatch instead of fixing one.
Streaming SSR and selective hydration
Classic SSR renders the whole page before sending anything, so one slow query holds the entire response hostage. Streaming SSR (renderToPipeableStream) sends the shell immediately; every <Suspense> boundary whose data isn't ready streams its fallback now and its HTML later, patched in as it resolves. Hydration is selective too: boundaries hydrate independently, and clicking inside a not-yet-hydrated one tells React to prioritize it. Where you put Suspense boundaries is therefore an architectural decision, because they define what the user gets instantly and what may arrive late.
Isomorphic code and environment leaks
The same module runs in Node and the browser, and the gotchas are all environment leaks: window/document access at module scope or in render crashes the server render (or worse, silently mismatches). Browser-only work belongs in effects and event handlers, the two places the server never executes. Corollaries: useLayoutEffect warns under SSR (nothing has painted yet, so there is no layout to measure), and libraries that touch the DOM on import need a dynamic import() from an effect. Push the same reasoning one step further and you reach the question behind server components: which parts of the tree need to ship to the browser at all.
How React Server Components differ from SSR
SSR answers "where does the first-paint HTML come from". RSC answers "which components exist in the client bundle at all". ❌ "RSC is just better SSR" / "RSC means no hydration": the two compose. Server components render on the server and stay there; client components still do both jobs, server-rendered to HTML and then shipped and hydrated.
The mental model is two module graphs. The server graph can import databases, fs, secrets, 200 KB markdown parsers; its components can be async and await data in render, which colocates data access with the UI that needs it and skips the fetch-on-mount waterfall. None of that code ships: a server component never hydrates and never re-renders. What crosses the wire is its output, a serialized UI description (the RSC payload), and the payload is also what lets client-side navigations render server content without a full page load.
"use client" looks like a per-component annotation but marks a module-graph boundary: that file and every transitive import become client code. Consequences:
- Placement. Push boundaries to the leaves: mark the interactive button, not the page. A boundary too high drags the whole subtree's imports into the bundle.
- Serialization. Props crossing from server to client must survive serialization: no functions (except server functions), no class instances. JSX is fine, which enables the escape hatch below.
- Composition. A server component can render a client component. A client module can never import a server component, but it can receive already-rendered server output as
children, which is how a server-rendered article sits inside a client-side<Expandable>: interactivity wraps server content without absorbing it.
How server functions and use() handle forms
Server functions ("use server") are the RSC payload's answer to mutations: pass one to <form action> and the framework serializes a reference to it; the code stays on the server. The payoff is progressive enhancement: the form submits before the bundle loads, or with JS off. useActionState(fn, initial, permalink?) layers state on top. The action receives (prevState, formData), you get back [state, formAction, isPending], and permalink keeps a no-JS submission landing on the right page. useOptimistic completes the set: show the expected result immediately, reconcile when the action settles. Together these replace the spinner-shaped useEffect around form submission.
use(promise) is the read half. It unwraps a promise in render, suspends to the nearest <Suspense>, rejects to the nearest error boundary, and unlike hooks it is legal in conditionals. The signature pattern: the server component starts a fetch without awaiting and passes the promise to a client component, which use()es it. The request begins server-side, early, and the waiting moves off the shell's critical path. One caveat: never create the promise during a client render, because a new promise every render means suspending forever. Cache it, or create it server-side.
Which caching layer serves your page
"Is this page static?" unpacks to "which layer serves it": build-time prerender (SSG) → ISR-style revalidation (time-based or on-demand after a mutation) → data-level caching and per-render request dedup below that → the router caching RSC payloads on the client. ❌ Blaming "React" for stale data that a framework data-cache served. The direction of travel (Next 16) makes the layers explicit: use cache on functions and components with declared lifetimes, and Partial Prerendering, where one route is a static shell served instantly from the CDN with <Suspense> holes streamed per request. Anything reading cookies()/headers()/searchParams is request-time by definition; the static/dynamic split happens inside a route, no longer per route.
When SSR is not worth it (and what to run instead)
A fully authenticated tool (admin panel, editor, B2B dashboard) has nothing cacheable, an LCP behind a login wall nobody measures, and hydration cost on a huge interactive tree: you render everything twice for a first paint the user sees for 200ms. Honest CSR with a fast static shell, or RSC mainly for the bundle win, beats reflexive SSR. In system-design rounds, "we don't need SSR here, and here is the cost model" grades as a senior answer.
Edge rendering buys TTFB by proximity to the user, and pays it straight back if the render needs three round trips to a single-region database. Edge fits static shells, middleware (auth redirects, geo, A/B rewrites to pre-rendered variants), and cached content. DB-heavy renders belong next to the DB.
Personalization, ranked by cost: a static or ISR shell plus a client fetch for the personal fragment (cheapest; the personal bits arrive late); an edge rewrite to pre-rendered variants (fast, and works for finite segments: locale, flags, A/B); full per-request SSR, the only option when the personalized content is the LCP content, and priced accordingly.
beta
The interviewer part is in the works.
The diagnostic, personal maps, and AI mock interviews are being finished right now. The notes stay free either way. Leave an email and you'll get the first-cohort invite, plus a month of Pro when it opens.