A7 · Part 3 · Advanced
Rendering strategies
Where the HTML is produced, and when — the decision that shapes everything downstream.
10On this page
The four, and what actually differs
| HTML made | By | Time to first byte | Freshness | |
|---|---|---|---|---|
| CSR | In the browser, after JS loads | the client | fast, but empty | always current |
| SSR | On every request | the server | slowest — waits for data | always current |
| SSG | At build time | the build | fastest — a file | as old as the deploy |
| ISR | At build, then regenerated | build + background | fastest | bounded staleness |
Client-side rendering
<div id="root"></div> // the entire document
The server sends an empty shell; the browser downloads the bundle, runs it, fetches data, and paints. Time to first byte is excellent and time to anything useful is not. Crawlers that do not execute JavaScript see nothing, and the whole experience is hostage to the bundle size.
Right for an app behind a login, where SEO is irrelevant and the first load happens once a day. Wrong for anything with a public landing page.
Server-side rendering
React renders to HTML on each request, and the client hydrates — attaches handlers to markup that already exists. The user sees content before the JavaScript arrives.
The cost is that TTFB now includes your slowest query. A 400ms database call means 400ms before a single byte moves. Streaming with Suspense is the fix: send the shell immediately, stream each boundary as its data resolves.
Static generation
Render at build time, serve files from a CDN. Nothing is faster and nothing is cheaper — there is no server in the request path at all. The constraint is that the content must be knowable at build time, and the cost is that changing one word means a rebuild and a redeploy.
export async function generateStaticParams() {
const posts = await getPosts();
return posts.map((p) => ({ slug: p.slug })); // one page per post
}
Incremental regeneration
export const revalidate = 3600; // serve cached, refresh at most hourly
A static file with an expiry. The first request after the window serves the stale page and triggers a background regeneration; everyone after gets the new one. You choose how stale is acceptable, and pay nothing for freshness you did not ask for.
On-demand revalidation is the sharper version: a webhook from the CMS regenerates exactly the pages that changed, so the content is fresh in seconds without a rebuild.
Hydration, and the two ways it hurts
The mismatch
<p>{new Date().toLocaleTimeString()}</p> // ✗ server time ≠ client time
<p>{Math.random()}</p> // ✗
<p>{localStorage.getItem("name")}</p> // ✗ no localStorage on the server
If the client's first render disagrees with the server's HTML, React warns and re-renders that subtree from scratch — losing the benefit of SSR exactly where it happened. The fix is to render the server-safe value first and correct it after mount:
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
return <p>{mounted ? localValue : fallback}</p>;
Browser-only components, since React 19.3
"use client";
import { use } from "react";
import { browser } from "react-dom";
function SavedName() {
use(browser()); // server: stop here. browser: carry on.
return <p>{localStorage.getItem("name")}</p>;
}
<Suspense fallback={<p>Loading…</p>}>
<SavedName />
</Suspense>
During server rendering, use(browser()) stops the component and
leaves the nearest <Suspense> fallback in the HTML. In the
browser it returns undefined and the component renders normally.
It replaces the mounted flag above, the
typeof window check, and a framework's "disable SSR" option —
and because the server never produced a value, there is nothing to mismatch.
- It needs a
<Suspense>boundary above it. Without one, the server render fails. - Client Components only. A Server Component always runs on the server, so the question never arises there.
- Pass it to
use. Callingbrowser()alone does nothing, and throwing it is wrong.
The second cost is simply that hydration is work: React walks the whole tree attaching handlers before the page is interactive. A page can look ready and ignore clicks for a second. Selective hydration and Server Components both exist to shrink that window.
Hydration mismatches: the usual causes, and the fix for each
Hydration reuses the server's HTML instead of rebuilding it, so the client's first render must produce exactly the markup the server sent. When it does not, React logs a mismatch and, in the worst case, throws the server HTML away and renders from scratch. React 19 prints a diff showing which text or attribute differed, so read the message before guessing.
| Cause | Why it differs | Fix |
|---|---|---|
new Date(), Date.now() | The server and the browser render at different moments | Render the time in an effect, or pass the value from the server as a prop |
| Locale or timezone formatting | The server runs in UTC with one locale, the user has another | Format with an explicit locale and timeZone |
Math.random(), generated ids | A different value each render | useId for ids; move randomness into an effect |
typeof window !== "undefined" in render | The branch is false on the server and true in the browser | Read browser-only values in an effect, or with useSyncExternalStore and a server snapshot |
localStorage or matchMedia read during render | Does not exist on the server | The same: an effect, or a store with getServerSnapshot |
Invalid HTML nesting (<div> inside <p>) | The browser's parser repairs it before React looks | Fix the markup |
| Browser extensions and translators | They edit the DOM before hydration | Nothing in your code is wrong; suppressHydrationWarning on the affected element if it is noisy |
// a value that is legitimately different on the client
function Clock() {
const [now, setNow] = useState(null); // null on the server and on the first client render
useEffect(() => setNow(new Date()), []);
return <time>{now ? now.toLocaleTimeString("en-GB") : ""}</time>;
}
// a single text node you accept will differ
<time suppressHydrationWarning>{new Date().toISOString()}</time>
suppressHydrationWarning is an escape hatch, not a fix. It works
one level deep, on that element's own text and attributes, and it only silences
the warning: the mismatched text stays until something re-renders it. Use it
for a genuine, harmless difference such as a timestamp, never to hide a bug.
The effect version renders the same thing on the server and the first client
pass, then updates — which is the pattern that is always correct.
Metadata and resources, hoisted
function Post({ post }) {
return (
<article>
<title>{post.title}</title> // hoisted to <head>
<meta name="description" content={post.summary} />
<link rel="canonical" href={post.url} />
...
</article>
);
}
React 19 hoists <title>, <meta> and
<link> out of wherever you render them and into the
document head, deduplicating as it goes. The component that knows the data
declares its own metadata, and libraries like Helmet stop being necessary.
import { preload, preconnect, prefetchDNS } from "react-dom";
preconnect("https://api.example.com"); // warm the connection
preload("/fonts/body.woff2", { as: "font" }); // start the download early
These let a component say what the browser will need before it needs it. The most valuable is usually the font or the LCP image: preloading the hero image can move the largest contentful paint by hundreds of milliseconds, because the browser stops discovering it late in the parse.
Choosing per route, not per app
The real answer in a modern framework is that this is not one decision. A marketing page is static, a blog post is ISR, a dashboard is server-rendered per request, and a settings panel behind auth can be client-rendered. Picking one strategy for an entire application is the mistake.
| Page | Strategy | Because |
|---|---|---|
| Landing, docs, marketing | SSG | Same for everyone, SEO matters |
| Blog, product catalogue | ISR | Changes occasionally, must stay fast |
| Personalised feed, dashboard | SSR | Different per user, must be current |
| Editor, admin behind a login | CSR | No SEO, heavy interaction |
The one number to check
For anything public, measure Interaction to Next Paint and the time between first paint and interactivity. A statically served page that ships a megabyte of JavaScript is not fast — it just looks fast until somebody clicks.
Interview answer, one sentence
"The strategies differ in where and when HTML is produced, and the trade is always freshness against latency: static is fastest and stalest, per-request SSR is current but pays your slowest query in TTFB, and ISR picks a bounded staleness in between. It is a per-route decision, and hydration mismatches are what quietly undo SSR's benefit."
Opens in the editor: write it, run it, and check it against real tests.
All 54 for this level →Finished reading?
0 of 57 chapters read in this series