Skip to content
Software Atlas

CSR, SSR, SSG & Hydration

Where your page's HTML gets built (in the browser, on the server per request, or once at build time) and what that does to what users see first. With React's real renderToString, hydrateRoot and streaming running live in the page.

⏱ ~60 min read15 sections11 interactive widgetsJSX · JavaScript · Go
FoundationsPractitionerStaff-level
Foundations§01

The 30-second version

You've built a React app with Vite. It works. Then someone asks: "Why is the page blank for two seconds on my phone?", "Why does Slack show no preview when I paste our link?", or "Should we move to Next.js (a React framework that renders on the server) for SSR?" All three are questions about the same thing: where your page's HTML gets built.

In one sentenceA browser can only show HTML. Your React components have to be turned into HTML somewhere: in the visitor's browser (CSR), on your server for each request (SSR), or once when you build the site (SSG). That choice decides what the user sees first, how fast, and what it costs you.
Where does the HTML get built?
Pick a strategy. The highlighted box is where your React components turn into HTML.
  1. Build time
    once, before anyone visits (npm run build)
  2. Server, per request
    every time someone opens the page
  3. Browser HTML built here
    on the visitor's device
Restaurant version
A meal kit: the box arrives fast, but you cook it yourself at the table.
What the browser receives
<div id="root"></div> + app.js
What the visitor sees first
Blank page until the JavaScript has downloaded, run and fetched its data.
Client-side rendering. In every strategy the same JavaScript still ships, so the page can become interactive.

There's a twist that the analogy needs one more piece for. With SSR and SSG the plate arrives looking perfect, but the buttons don't work yet. HTML can't carry JavaScript functions, so the browser still has to download your code and run it to attach click handlers. That step is called Hydration, and most of the surprises in this topic live there.

1

HTML first, JavaScript later

The browser paints HTML as soon as it arrives. JavaScript arrives later and makes it interactive. CSR sends almost no HTML, so the user waits for the JavaScript (§02–03).
2

Hydration must match

After SSR, React re-runs your components in the browser and attaches to the existing HTML. If the browser's result differs from the server's (a date, a random number), React reports a mismatch (§05–06).
3

Pick per page

A marketing page, a product page and a logged-in dashboard want different strategies. Modern frameworks let you choose per route (§11).

Here are the two entry points you'll see over and over. The only difference is one function name, and it changes everything about what the browser does:

main.jsx (CSR vs SSR/SSG)
import { createRoot, hydrateRoot } from "react-dom/client";
import { App } from "./App.jsx";

const el = document.getElementById("root");

createRoot(el).render(<App />);   // CSR: the div is empty, build every DOM node from scratch
// or
hydrateRoot(el, <App />);         // SSR/SSG: the HTML is already there, adopt it and attach handlers

The rest of the topic builds this up one step at a time: what arrives in the browser (§02), CSR (§03), SSR (§04), hydration and its mismatches (§05–06), building at build time with SSG and revalidation (§07), streaming (§08), Server Components (§09), islands (§10), how to choose (§11), and what goes wrong in production (§12–13). It assumes you know what a render is; if not, skim How React Works → Render, then commit first.

Foundations§02

HTML first, JavaScript later

In one sentenceWhen you open a page, the browser gets HTML first and can draw it right away. Your JavaScript is a separate file that downloads and runs afterwards. So whatever is in that first HTML is what people see first.

What happens when you press Enter

Here's a page load, slowed down. The browser does these in order, and it can only start painting once it has some HTML:

A page load, simplified
network
HTML
app.js (downloading)
browser
parse + paint
idle
run app.js
screen
blank
whatever the HTML contained
+ what JS added
The browser reads the HTML top to bottom and paints as early as it can. A <script type="module"> does not block that: it downloads in the background and runs after the HTML has been read.
  1. The browser asks your server (or a CDN) for the page and gets back an HTML document.
  2. It reads the HTML, builds the DOM (the browser's tree of page objects) and paints it. If the HTML contains text, the user sees text. If it contains an empty <div>, they see nothing. (The full pipeline is in Browser Rendering → parsing.)
  3. Meanwhile it downloads the <script> files the HTML points to. Only when one has downloaded and run can it change the page or respond to clicks.

Three moments worth naming

Every strategy in this topic moves these three moments around, so they get names:

MomentNameWhat the user experiences
The HTML starts arrivingTime to first byte (TTFB)Still a blank tab, but the browser can start working
First real text or image is drawnFCP (and LCP for the biggest element)"It loaded"
Clicks and typing actually workInteractive"It works"

The trap: "it loaded" and "it works" are different moments, and different strategies put the gap in different places. LCP is one of Google's Core Web Vitals, which the Web Performance topic covers in depth.

See it: the same page, two ways

Toggle between a client-rendered and a server-rendered version of the same tiny menu page. The left side is the exact HTTP response. The right side is what the browser can draw from that response before any JavaScript runs:

What the server sends, and what you see before JavaScript runs
The SSR HTML below is produced live, in this page, by React's real renderToString.
HTTP response body167 bytes
<!doctype html>
<html>
  <head><title>Menu</title></head>
  <body>
    <div id="root"></div>
    <script type="module" src="/assets/app.js"></script>
  </body>
</html>
Screen before app.js has runJavaScript off, or still downloading
(blank white page)
CSR: the body is an empty div. Search engines that don't run JavaScript, link previews, and every user on a slow phone see nothing until app.js runs.

Two things to notice:

  • CSR's response is almost empty. All the content is built later by app.js. Before then the page is white.
  • SSR's button is drawn but dead. It's real HTML, so it shows up immediately. But the onClick handler is JavaScript, and none has run yet. We'll come back to this gap in §05.

Who else reads that first HTML

Not every visitor is a person with a fast laptop:

  • Search engines and link previews. A crawler downloads your page to index it, or to build the card you see when you paste a link into Slack. Google does run JavaScript, but in a second, delayed pass. Many link-preview bots don't run it at all, so a CSR page shows up as a blank card.
  • Slow phones. Downloading and running a 300 kB JavaScript bundle (all your app's code plus React, packed into one file or a few) can take a few seconds on a cheap phone over mobile data. With CSR, that whole time is a blank screen.
  • When JavaScript fails. A bundle that 404s after a deploy, a blocked script, a JavaScript error at startup: with CSR the page stays blank; with server-built HTML the content is still readable.

None of this means CSR is wrong. It means the first HTML matters, and §03 onward shows what each strategy puts in it.

Foundations§03

Client-side rendering (CSR)

In one sentenceWith CSR, the server sends the same nearly empty HTML to everyone, plus a JavaScript bundle. The browser downloads the bundle, runs your components, fetches data, and builds every DOM node itself.

This is what npm create vite@latest gives you. The whole HTML file looks like this, for every URL of your app:

index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Menu</title>
  </head>
  <body>
    <div id="root"></div>                              <!-- ← empty: nothing to paint yet -->
    <script type="module" src="/src/main.jsx"></script> <!-- ← everything happens in here -->
  </body>
</html>

And the JavaScript that fills it:

main.jsx
import { useEffect, useState } from "react";
import { createRoot } from "react-dom/client";

function App() {
  const [dishes, setDishes] = useState(null);
  useEffect(() => {
    fetch("/api/dishes")                    // ← data request starts only AFTER the first render
      .then((r) => r.json())
      .then(setDishes);
  }, []);
  if (!dishes) return <p>Loading…</p>;
  return <ul>{dishes.map((d) => <li key={d.id}>{d.name}</li>)}</ul>;
}

createRoot(document.getElementById("root")).render(<App />); // ← build the DOM from nothing

The waterfall

Look at the order of events that has to happen before the user sees a single dish. Each step can only start when the one before it has finished:

  1. Download index.html (tiny, fast).
  2. Download the JavaScript bundle (React plus your app: often 100–500 kB).
  3. Parse and run it. React renders Loading….
  4. The effect fires and starts fetch("/api/dishes"). Another round trip to the server, which queries a Database.
  5. The data arrives. React renders the list. Now the user sees content.

A chain of requests where each one waits for the previous is called a waterfall. CSR has one built in: the data request can't start until the code that makes it has arrived and run.

Measured: CSR vs SSR vs SSG

We built the same 40-item React page three ways and loaded each one in headless Chromium with a throttled network and CPU, to imitate a mid-range phone on mobile data. Press play, or drag the slider, to watch the three loads side by side:

The same page, three ways: when can the user see it, and when can they click?
Measured on our test machine (a script-driven Chromium with a slowed network and CPU). Middle value of 7 loads each.
CSRHTML (empty) → download app.js → run it → fetch /api/dishes → render
interactive
HTML arrives: 153 msfirst content: ≈1270 msclickable: ≈1270 ms
SSRserver waits for the 300 ms query, renders, sends HTML → paint → download app.js → hydrate
interactive
HTML arrives: 454 msfirst content: ≈536 msclickable: ≈1060 ms
SSGpre-built HTML sent instantly → paint → download app.js → hydrate
interactive
HTML arrives: 153 msfirst content: ≈228 msclickable: ≈769 ms
1400 ms
blank screen HTML arriveslooks ready, not clickableinteractive
StrategyHTML arrivesFirst contentClickable
CSR153 ms≈ 1,270 ms≈ 1,270 ms
SSR454 ms≈ 536 ms≈ 1,060 ms
SSG153 ms≈ 228 ms≈ 769 ms
Measured on our test machine, in a headless Chromium (a real browser run by a script, without a window): download speed limited to 1.6 Mbit/s, CPU slowed 4×, 150 ms added to every response to imitate distance, a 300 ms "database" query, a 68 kB bundle (after gzip compression). Each number is the middle value (median) of 7 loads. Your numbers will differ; the order won't.

Read the CSR row carefully. Its HTML arrived fastest (tied with SSG), because the server didn't have to do anything. But the user saw nothing for about 1.3 seconds: the bundle download, the startup, then the API round trip, all in a row. On the other hand, once it painted, it was already interactive. There's no "looks ready but isn't" phase, because the same JavaScript that drew the page attached the handlers.

What CSR is genuinely good at

🧱
Dead simple hostingThe output is static files. Put them on any CDN or file host. No server to run, scale or patch.
⚡
Fast after the first loadNavigating between pages only fetches data; the code is already loaded. This is what makes a Single-page app feel like an app.
🧠
One mental modelAll code runs in one place, the browser. window, localStorage and the screen size are always available.
🔒
Fine behind a loginCrawlers can't see logged-in pages anyway, and users of a dashboard or editor stay for a long time, so one slower start matters less.

Where it hurts

  • First load on slow devices. Everything the user sees waits on the biggest download and the slowest CPU. Code splitting helps: cutting the bundle into several files so the first page only downloads what it needs (Web Performance covers it), but the waterfall stays.
  • Public pages that need to be found or shared. Product pages, articles and landing pages lose the link previews and the reliable search indexing described in §02.
  • Blank on failure. If the bundle fails to load, there is no page.

The fix for all three is the same idea: send real HTML in the first response. That means building it before the browser asks: either per request (§04) or ahead of time (§07).

Foundations§04

Server-side rendering (SSR)

In one sentenceWith SSR, your server runs the React components for each request, turns the result into an HTML string, and sends that. The browser can paint real content immediately; the JavaScript still follows to make it interactive.

The same components, on a server

React can run anywhere JavaScript runs, including a Node.js server. The package react-dom/server has a function, renderToString, that calls your components once and gives back HTML instead of touching a DOM. Try it: this widget runs the real function, in this page, every time you change the "request":

renderToString, live
Change the 'request'. Real react-dom/server runs in this page and returns the HTML a server would send.
function Greeting({ user, items }) {
  return (
    <section className="card">
      <h2>Hello, {user || "guest"}</h2>
      {items.length ? (
        <ul>{items.map((it) => <li key={it}>{it}</li>)}</ul>
      ) : (
        <p>Your basket is empty.</p>
      )}
      <button onClick={() => alert("hi")}>Checkout</button>
    </section>
  );
}

// On the server, for this request:
const html = renderToString(<Greeting user={user} items={items} />);
Items in basket
2
Returned string0 chars · 0.00 ms
Notice what's missing: the onClick handler. HTML can't carry a JavaScript function, so the button arrives dead. The <!-- --> comment after “Hello,” marks where two separate text pieces meet, so the browser-side React can find them again later (§05).

A whole SSR server is that call, plus a little plumbing. Here's one with no framework, using only Node's built-in http module:

server.jsx (Node 22)
import http from "node:http";
import { renderToString } from "react-dom/server";
import { App } from "./App.jsx";
import { db } from "./db.js";

// Turn data into JSON that is safe to put inside a <script> tag (see §12 for why)
const safeJson = (v) => JSON.stringify(v).replace(/</g, "\\u003c");

http.createServer(async (req, res) => {
  try {
    const dishes = await db.getDishes();                     // ← 1. fetch data on the server
    const html = renderToString(<App dishes={dishes} />);   // ← 2. run the components → HTML
    res.setHeader("Content-Type", "text/html; charset=utf-8");
    res.end(`<!doctype html>
      <html><body>
        <div id="root">${html}</div>
        <script>window.__DATA__ = ${safeJson(dishes)}</script>
        <script type="module" src="/assets/client.js"></script>
      </body></html>`);                                       // ← 3. send a full page
  } catch (err) {
    console.error(err);                                      // database down, render crashed…
    res.statusCode = 500;
    res.end("Sorry, the menu could not be loaded.");         // always answer the request
  }
}).listen(3000);

Why put the data in the page as well as in the HTML? Because the browser is about to run the same App component to make the page interactive (§05), and it needs the same dishes to produce the same output. Sending them along saves a second fetch and guarantees both sides start from identical data.

SSR isn't a React invention

Servers built HTML per request for decades before React, with a Template engine. Here's the same page in Go with the standard library's html/template. If you've used PHP, Rails, Django or Go templates, you've done SSR:

menu.go
// Parsed once at startup. {{range .}} loops over the data; {{.Name}} is HTML-escaped automatically.
var menuPage = template.Must(template.New("menu").Parse(`<!doctype html>
<title>Menu</title>
<h1>Today's menu</h1>
<ul>{{range .}}<li>{{.Name}} — €{{.Price}}</li>{{end}}</ul>`))

func menu(w http.ResponseWriter, r *http.Request) {
	dishes, err := loadDishes(r.Context()) // ← fresh data for THIS request
	if err != nil {
		http.Error(w, "could not load the menu", http.StatusInternalServerError)
		return
	}
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	if err := menuPage.Execute(w, dishes); err != nil { // ← HTML built here, per request
		log.Println("render:", err)
	}
}

What React adds is that the same component code produces the HTML on the server and takes over in the browser afterwards. With a Go template, any interactivity has to be written separately in JavaScript.

What you pay for it

Go back to the measurements in §03. SSR's first content came at about 536 ms instead of 1,270 ms. But look at when its HTML arrived: 454 ms, against 153 ms for CSR. The server held the response while it waited for the 300 ms database query and rendered. Three costs follow from that:

  1. Later first byte. The browser can't do anything until the server finishes. A slow query on the server means a blank tab with a spinning icon. (Streaming fixes much of this, §08.)
  2. A server, working on every request. CSR and SSG can be plain files on a CDN. SSR needs a running process, and 1,000 visitors means 1,000 renders. Rendering is CPU work: we measured about 2.5 ms for a simple 1,000-row table on this machine, and real pages with many components and data shaping often cost more (§12).
  3. Code that runs in two places. The same component now runs on the server, where window, document and localStorage don't exist, and in the browser. Code that touches them during render crashes on the server, or worse, produces different output (§06).

And there's one gap SSR adds that CSR didn't have. Look at the SSR row in the timeline again: content at 536 ms, clickable at 1,060 ms. For half a second the page looked ready while the buttons did nothing. That gap is called the Uncanny valley. What happens at the end of it is hydration, and it's the next section.

Practitioner§05

Hydration: making server HTML come alive

In one sentenceHydration is React running your components in the browser a second time, over HTML that's already on screen. Instead of creating DOM nodes, it adopts the ones the server sent and attaches the event handlers. Only then do the buttons work.

The browser side of an SSR app is a few lines. It uses hydrateRoot instead of createRoot, and it renders with the data the server put in the page (§04):

client.jsx
import { hydrateRoot } from "react-dom/client";
import { App } from "./App.jsx";

hydrateRoot(
  document.getElementById("root"),
  <App dishes={window.__DATA__} />   // ← same component, same data as the server used
);

Step through it

Hydration, step by step
Step with ← →. Watch the DOM column: React adopts the nodes, it doesn't recreate them.
Code
1// client.jsx — the browser entry point
2import { hydrateRoot } from "react-dom/client";
3import { App } from "./App.jsx";
4
5hydrateRoot(document.getElementById("root"), <App />);
6
7// App.jsx — the SAME component the server rendered
8export function App() {
9 const [n, setN] = useState(0);
10 return <button onClick={() => setN(n + 1)}>Clicked {n} times</button>;
11}
React (browser)
no JavaScript has run yet
DOM (from server HTML)
<button>Clicked <!-- -->0<!-- --> times</button>
What the user gets
Clicked 0 times (looks real)
The server's HTML has arrived and the browser has painted it. The user can read the button, but there is no click handler on it: HTML can't contain JavaScript functions. Clicking now does nothing.
1 / 7

The key move is step 5. React doesn't diff the server's HTML as one big string. It renders your components into elements as usual, then walks the existing DOM tree alongside them, node by node. At each node it checks that the tag matches (a button where it expects a button) and that text nodes hold the text it just rendered, and records "this element is that <button>". If a check fails, that's a hydration mismatch (§06). That's why the result has to fit: React assumes the DOM it finds is the DOM it would have built.

Try it for real

This lab runs both halves in your browser. Step 1 calls React's real renderToString and puts the HTML into the white box, exactly as a server response would. Step 2 calls the real hydrateRoot on it. Keep the case on Matching for now, and click the button between steps 1 and 2:

Hydration lab
Real React: step 1 runs renderToString, step 2 runs hydrateRoot on that HTML. Pick a case first.
<p>Hello from the same markup on both sides</p>
Phase: nothing yet
The page (sandbox)
Press “1 · Server render”.
Pick a case, then press “1 · Server render”.

What the lab shows:

  1. Before hydration, clicks do nothing. The HTML button is real and visible, but no handler is attached. Those clicks are simply lost: React wasn't running when they happened, so it can't replay them.
  2. After hydration, React kept the same DOM node. The widget checks whether the <button> element that was there before step 2 is still in the page afterwards. It is: hydration attached to it rather than replacing it. That's what makes hydration invisible to the user: no flicker, and a half-typed input keeps its text.

What hydration costs

It's easy to think "the server already rendered it, so the browser has less to do". Not really. During hydration the browser still:

  • downloads the whole JavaScript bundle, the same size as with CSR;
  • runs every component on the page once, to know which handler belongs to which node;
  • just skips creating the DOM nodes, which is a fairly small part of the work.

So SSR moves when the user first sees content, not how much JavaScript the browser runs. On a slow phone the Uncanny valley can last seconds: the page looks finished, the user taps "Add to cart", and nothing happens. §08 (streaming), §09 (Server Components) and §10 (islands) are three different attacks on exactly this cost.

Practitioner§06

Hydration mismatches: when server and browser disagree

In one sentenceHydration only works if the browser's first render produces exactly what the server produced. Anything that differs between the two machines (the time, the screen, a random number, the locale) makes them disagree, and React has to throw work away.

See one happen

Same lab as §05, now starting on a mismatch. In Mismatch: time, the component prints the current time with milliseconds. Step 1 renders it on the "server"; when you press step 2 a moment later, the browser's render prints a later time. Mismatch: window imitates the classic typeof window check. Fixed: useEffect shows the repair.

Hydration lab
Real React: step 1 runs renderToString, step 2 runs hydrateRoot on that HTML. Pick a case first.
<p>Rendered at {new Date().toLocaleTimeString()}</p>  // server time ≠ browser time
Phase: nothing yet
The page (sandbox)
Press “1 · Server render”.
Pick a case, then press “1 · Server render”.

When the text doesn't match, React 19 reports this in a development build (the lab captures the real message):

Hydration failed because the server rendered text didn't match the client.

In a production build, React ships without its long error texts to save bytes, so the same error arrives as just "Minified React error #418" with a link to react.dev that explains it. If you're reading this page on the production site, the lab shows that code and the readable text next to it.

Then it does something drastic: it discards the server's DOM for that part of the page and renders it again from scratch in the browser. The lab checks the <button> node before and after: on a mismatch, the old node is gone. Everything the server did for that part was wasted, the user may see the content flicker, and any text they had typed into an input there is lost.

What React does, by kind of difference

What differsWhat React doesVisible symptom
Text, or the element structureReports a hydration mismatch; throws away the server DOM up to the nearest Suspense boundary (or the whole root) and re-renders in the browserFlicker, lost input, slower page
An attribute (className, style, href…)In development, logs "A tree hydrated but some attributes of the server rendered HTML didn't match the client properties. This won't be patched up."The server's value stays. The page keeps the wrong class or link until something re-renders it

That second row surprises people. We checked it with React 19.3: hydrating <p class="server"> with a component that renders className="client" leaves class="server" in the DOM, silently in production.

The usual suspects

CauseExampleFix
Timenew Date().toLocaleTimeString(), "posted 3 minutes ago"Pass the time from the server, or fill it in after hydration (below)
Time zone and localedate.getHours(), n.toLocaleString(): the server runs in UTC with en-US, the user doesn'tFormat with an explicit zone and locale, e.g. Intl.DateTimeFormat("de-DE", { timeZone }), decided on the server and passed down
RandomnessMath.random(), crypto.randomUUID() for an iduseId() for ids; generate random values once on the server and pass them
Browser-only valuestypeof window !== "undefined", window.innerWidth, localStorage.getItem("theme")Render the same thing on both sides first, then update after hydration; or use CSS media queries for layout
Invalid HTML nesting<p><div>…</div></p>, <a> inside <a>Fix the markup. The browser's HTML parser "repairs" it into a different tree
Data that changedThe browser refetches instead of using the server's dataHydrate with the data the server used (window.__DATA__, §04)
Browser extensionsTranslators, password managers, Grammarly adding attributes or nodesMostly harmless (often attributes); nothing to fix in your code

The nesting one is sneaky, because your code renders the same thing on both sides. The problem is the browser. We fed <p><div>block</div></p> to the browser's parser and got back <p></p><div>block</div><p></p>: a <div> isn't allowed inside a <p>, so the parser closes the paragraph early. Now the DOM has three elements where React expects one. React warns about invalid nesting in development, so read those warnings.

The standard fix: render twice

When something can only be known in the browser, make the first browser render match the server, then update. An effect runs only in the browser and only after hydration, so it's the natural place:

LocalTime.jsx
import { useEffect, useState } from "react";

export function LocalTime() {
  const [time, setTime] = useState(null);         // ← server AND first browser render: null
  useEffect(() => {
    setTime(new Date().toLocaleTimeString());    // ← browser only, after hydration
  }, []);
  return <p>Rendered at {time ?? "…"}</p>;        // both sides first render "…"
}

That's the Fixed: useEffect case in the lab: no error, React keeps the server's DOM, and the time pops in right after hydration as an ordinary update. The cost is a brief placeholder. Use it for small things (a timestamp, a "Hi, Ada" in the header), not for the main content, or you're back to CSR for that part.

Two smaller tools

  • useId() gives an id that's the same on the server and in the browser, because React derives it from the component's position in the tree. Use it to connect a <label htmlFor> to an <input id> instead of a random id.
  • suppressHydrationWarning on an element tells React "this element's own text may differ; keep the server's". We checked: React 19.3 left the server text in place and reported nothing. It only applies one level deep (that element's text and attributes, not its children), and the user keeps seeing the server's value, so it's for timestamps that are "close enough", not a general off switch.

The theme flash

A classic: the user's dark/light choice is stored in localStorage. The server can't read localStorage, so it renders the light theme; the browser then switches to dark, and the page flashes white on every load. The fix is to put the choice somewhere the server can read: a cookie is sent with every request. Or run a tiny inline <script> in the <head> that sets a class before the first paint. (Browser Storage compares where to keep things like this.)

Practitioner§07

Build it once: SSG and revalidation

In one sentenceWith SSG, you run the server-rendering step once, at build time, for every page, and save the HTML as files. Every visitor gets a file from a CDN: no server work, no waiting for a database.

SSG is SSR, run ahead of time

It's the same react-dom/server idea from §04, moved from request time to Build time. React 19 has a function made for it, prerender from react-dom/static: unlike renderToString, it waits for all data to load before it returns, which is what you want when nobody is waiting.

build.jsx (runs during npm run build)
import { mkdir, writeFile } from "node:fs/promises";
import { prerender } from "react-dom/static";
import { App } from "./App.jsx";
import { db } from "./db.js";

const safeJson = (v) => JSON.stringify(v).replace(/</g, "\\u003c"); // same as §04

for (const dish of await db.getAllDishes()) {
  const { prelude } = await prerender(<App dish={dish} />); // ← waits for ALL data, then renders
  const body = await new Response(prelude).text();          // stream → string
  const html = `<!doctype html>
<html>
  <head><meta charset="utf-8" /><title>Menu</title></head>
  <body>
    <div id="root">${body}</div>
    <script>window.__DATA__ = ${safeJson(dish)}</script>
    <script type="module" src="/assets/client.js"></script>
  </body>
</html>`;                                                   // ← the same page shape as §04
  await mkdir(`dist/dish/${dish.slug}`, { recursive: true });
  await writeFile(`dist/dish/${dish.slug}/index.html`, html); // ← one file per page
}

The data has to travel with the page, exactly as in §04: the browser will run App again during hydration (§05), and it needs the same dish to produce the same HTML. (Here App is a one-dish page, so its client entry would call hydrateRoot(root, <App dish={window.__DATA__} />) rather than passing the dishes list from §05.) SSG pages hydrate exactly like SSR pages; the only difference is when the HTML was made.

The output is a folder of plain files. You upload it to a CDN, which keeps copies in cities around the world, so a visitor in Tokyo gets the file from Tokyo. That's why SSG won the measurements in §03: first content at about 228 ms, with nothing to compute.

Telling caches how long a page is good for

A CDN decides how long it may reuse a copy from the Cache-Control header. If your pages come from your own server (say, a Go service in front of the dist folder), you set it there:

static.go
func dishPage(w http.ResponseWriter, r *http.Request) {
	// Browsers: always check again (max-age=0).
	// CDN: reuse your copy for 60 s, then serve it stale for up to 5 min while you fetch a new one.
	w.Header().Set("Cache-Control", "public, max-age=0, s-maxage=60, stale-while-revalidate=300")
	http.ServeFile(w, r, filepath.Join("dist", "dish", path.Base(r.PathValue("slug")), "index.html"))
}

// Registered with: http.HandleFunc("GET /dish/{slug}", dishPage)   (Go 1.22+ route patterns)

Where SSG runs out

Do the arithmetic for a shop with 20,000 products. If each page takes 50 ms to fetch its data and render (an assumed, round number), a full build spends 20,000 × 50 ms = 1,000 s, about 17 minutes, just rendering. And then:

  • A price change needs a rebuild. Until then, every visitor sees the old price.
  • Nothing per user. The file is the same for everyone, so "Hi, Ada" or the cart count has to be filled in by JavaScript in the browser after load.
  • Pages that don't exist yet. A product added at 10:05 has no file until the next build.

Incremental regeneration: a cache that refreshes itself

The fix most frameworks offer is to keep the pre-built pages but let each one expire. When a visitor asks for a page that's older than, say, 10 seconds, the server hands over the old copy immediately and rebuilds the page in the background; the next visitor gets the new one. Next.js calls this incremental static regeneration (ISR). Underneath, it's stale-while-revalidate applied to whole pages.

Play with it. Time only moves when you press a Wait button, so you can follow each step:

Incremental regeneration, simulated
A pre-built page with "revalidate: 10 s". Time only moves when you press a Wait button.
Clock
t = 0 s
Database (the truth)
€9
Cached page on the CDN
€9
built 0 s ago · fresh
  1. t=0s Build: page generated with price €9 and put on the CDN.
Try: edit the price, have a visitor come, wait 5 s twice, visitor, wait 1 s ×2, visitor. Count how many visitors saw the old price.

Things to try, and what they teach:

  1. Edit the price, then send a visitor. They get the old price, and nothing else happens: the page is under 10 s old, so it still counts as fresh. Editing the database doesn't touch the cache.
  2. Wait until the page is older than 10 s, then send another visitor. They also get the old price instantly, but their visit triggers the rebuild. Someone had to see the stale version to set it off.
  3. Send a visitor while the rebuild is running. Still the old price. Only after the rebuild finishes does the cache change.
  4. Use on-demand revalidation. Instead of waiting for the timer, your CMS calls a Webhook when someone hits Save, and the page is marked stale right away. That's On-demand revalidation. It shrinks the window of wrong prices without rebuilding pages nobody changed.

So ISR trades a little freshness for SSG speed: you choose how stale is acceptable per page. A blog post can be an hour old; a stock level probably can't.

Practitioner§08

Streaming SSR: send what's ready, now

In one sentencePlain SSR waits for the slowest query before it sends a single byte. Streaming SSR sends the page shell right away, with placeholders, and then sends each slow section later in the same response, as soon as its data is ready.

The problem, in numbers

A product page needs three things: the product (a 50 ms query), its reviews (1.2 s, a slow service) and recommendations (2.4 s, a slower one). With renderToString (§04), the server must have all the data before it can render, so:

  • the user stares at a blank tab for 2.4 s, even though the product name and price were ready after 50 ms;
  • the slowest, least important part of the page decides how fast the most important part shows up.

Marking the slow parts: Suspense

You tell React which parts may arrive late by wrapping them in a Suspense boundary. Inside, a component reads its data with React 19's use():

ProductPage.jsx
import { Suspense, use } from "react";

function Reviews({ reviewsPromise }) {
  const reviews = use(reviewsPromise);   // ← not ready yet? React shows the fallback instead
  return <ul>{reviews.map((r) => <li key={r.id}>{r.text}</li>)}</ul>;
}

export function ProductPage({ product, reviewsPromise }) {
  return (
    <main>
      <h1>{product.name}</h1>                              {/* ready now */}
      <Suspense fallback={<p>Loading reviews…</p>}>         {/* may arrive later */}
        <Reviews reviewsPromise={reviewsPromise} />
      </Suspense>
    </main>
  );
}

The server side

On Node you use renderToPipeableStream instead of renderToString. It calls you back when the Shell (everything outside pending boundaries, plus their fallbacks) is ready:

server.jsx (Node 22)
app.get("/product/:id", async (req, res) => {
  const product = await db.getProduct(req.params.id);  // fast: wait for it
  if (!product) return res.status(404).send("No such dish"); // ← decide the status BEFORE streaming
  const reviewsPromise = db.getReviews(product.id);     // slow: DON'T await, pass the promise

  const { pipe, abort } = renderToPipeableStream(
    <ProductPage product={product} reviewsPromise={reviewsPromise} />,
    {
      bootstrapModules: ["/assets/client.js"],
      onShellReady() {                                   // ← shell rendered: start sending
        res.setHeader("Content-Type", "text/html; charset=utf-8");
        pipe(res);
      },
      onShellError() {                                   // ← the shell itself failed
        res.status(500).send("<h1>Something went wrong</h1>");
      },
      onError(err) {
        console.error(err);                              // log errors inside boundaries
      },
    }
  );
  setTimeout(abort, 10_000);                             // give up on stragglers after 10 s
});

Watch a real stream

The lab runs renderToReadableStream in this page, with two fake queries (1.2 s and 2.4 s). Every chunk React produces is logged with its arrival time, and written straight into a little browser frame, the way a real tab receives it. Run it in Stream mode, then in Wait for all data mode, and compare when the heading appears:

Streaming lab
Real React: renderToReadableStream runs in this page. Each chunk is written into the preview the moment it arrives.
function Page() {
  return (
    <main>
      <h1>Pizza Margherita</h1>
      <p>€9 · ready in 15 min</p>
      <Suspense fallback={<p>Loading reviews…</p>}>
        <Reviews />    {/* 1.2 s query */}
      </Suspense>
      <Suspense fallback={<p>Loading recommendations…</p>}>
        <Recs />       {/* 2.4 s query */}
      </Suspense>
    </main>
  );
}
renderToReadableStream: send what's ready now
Preview (an iframe fed chunk by chunk)
Chunks receivedclick one to see its HTML
nothing yet

What's inside the chunks

Open the chunks in the lab. Here's what React actually sent (taken from its output, scripts shortened):

chunk 1, at ≈ 0 ms: the shell
<main><h1>Pizza Margherita</h1><p>€9 · ready in 15 min</p>
  <!--$?--><template id="B:0"></template><p>Loading reviews…</p><!--/$-->
  <!--$?--><template id="B:1"></template><p>Loading recommendations…</p><!--/$-->
</main>
chunk 2, at ≈ 1,200 ms: one boundary's content
<div hidden id="S:0"><ul><li>“Crispy crust, perfect.” ★★★★★</li>…</ul></div>
<script>/* $RC = a ~1 kB helper, sent once */ $RC("B:0", "S:0")</script>
  1. In the shell, each pending boundary is marked with comments (<!--$?--> means "still loading") and an empty <template id="B:0"> as a bookmark, with the fallback inside.
  2. When the data arrives, React renders that boundary and sends its HTML in a hidden <div id="S:0">, followed by a tiny script.
  3. The script, $RC("B:0", "S:0"), moves the hidden content into the bookmark's place and removes the fallback. The user sees the reviews pop in.

All of this happens in one HTTP response that simply stays open until the last boundary is done. Because sections can be finished in any order, this is sometimes called out-of-order streaming.

Hydration gets smarter too: selective hydration

Each Suspense boundary also becomes a separate unit of hydration. React can hydrate the shell while the reviews are still streaming, and hydrate the reviews once their HTML and code are there. If the user clicks inside a part that isn't hydrated yet, React hydrates that part first and then replays the click. This is Selective hydration, and it shortens the Uncanny valley from §05 for the parts users actually touch.

The trade-offs

Headers go out with the shell

The HTTP status code and headers are sent with the first chunk. Once the shell has been sent, you can't change your mind: no 404 Not Found, no redirect, no Set-Cookie. So anything that decides those (does the product exist? is the user logged in?) must happen before onShellReady, outside the Suspense boundaries. In the server code above, that's why getProduct is awaited first and the 404 is sent before renderToPipeableStream is even called.

  • Errors after the shell can't turn into an error page. If a component inside a boundary throws on the server, React reports it to onError and tells the browser to try rendering that boundary itself.
  • Crawlers may want everything. A Crawler that doesn't run JavaScript only sees fallbacks for content that's swapped in by script. For bots, wait for onAllReady (or await stream.allReady, the Wait for all data mode in the lab) before sending. That's also exactly what prerender does for SSG (§07).
  • Something in the middle can buffer it. Some proxies, compression settings and hosting platforms collect the whole response before forwarding it, which silently turns streaming back into "wait for everything". If the lab-style behavior doesn't show up in production, check your proxy's buffering settings.
Staff-level§09

React Server Components

In one sentenceA Server Component runs only on the server (or at build time). The browser receives what it rendered, never its code. Only components marked "use client" are sent to the browser and hydrated. So a page can be mostly server-rendered with zero JavaScript for the parts that never change.

Why bother: the hydration bill

§05 ended on a problem: hydration downloads and runs every component, even the ones that will never change after the first render. A blog article with one Like button ships the code for every paragraph, the Markdown parser and the date-formatting library, only so that React can confirm the HTML matches and attach one click handler.

React Server Components split components into two kinds:

Server Component (the default)Client Component
Marked bynothing"use client" at the top of its file (or of a file that imports it)
Runson the server, once per request or at build timeon the server for the HTML, then in the browser
Its code is sent to the browser?NoYes
Can be async and await data, read files, use secretsYesNo
Can use useState, useEffect, onClickNoYes

One request, step by step

One Server Components request
Step with ← →. Follow what runs where, and what crosses the network.
Code
1// ── file 1: ProductPage.jsx — a Server Component (the default) ──
2import { db } from "./db.js";
3import { AddToCart } from "./AddToCart.jsx";
4
5export default async function ProductPage({ id }) {
6 const p = await db.products.find(id); // runs on the server
7 return (
8 <main>
9 <h1>{p.name}</h1>
10 <AddToCart productId={p.id} />
11 </main>
12 );
13}
14
15// ── file 2: AddToCart.jsx — a Client Component ──
16"use client"; // ← the first line of this file
17import { useState } from "react";
18
19export function AddToCart({ productId }) {
20 const [n, setN] = useState(0);
21 return <button onClick={() => setN(n + 1)}>Add ({n})</button>;
22}
Server
GET /product/7
Over the network
—
Browser
waiting
A request arrives. The framework calls the Server Component ProductPage. It's an async function: Server Components may await directly, because they never run in the browser.
1 / 7

The thing that crosses the network in step 4 is the RSC payload: the finished element tree, with holes like $L1 that mean "client component #1 goes here, with these props". On the first visit the server also turns that tree into HTML (ordinary SSR, §04), so the user sees content immediately. On later navigations only the payload is fetched, and React merges it into the page like a re-render, so client components that stay on screen keep their state.

Where to draw the line

The rule that trips everyone up: "use client" marks a boundary, not a single component. The marked file and everything it imports become client code. Try placing the boundary in this product page and watch the JavaScript total:

Where does 'use client' go?
Click a component to put 'use client' at the top of its file. Everything it imports becomes client code too.
JavaScript sent for these components: 7 kB of 86 kB
Sizes are illustrative, not measured. The point is the shape: libraries used only by server components never reach the browser.

What the tree shows:

  1. Push "use client" down to the leaves. Marking only SearchBox and AddToCart keeps the Markdown and date libraries on the server. Marking Page (the "classic SSR" button) ships everything, which is what §04–08 did.
  2. Interactive components must be client. Unmark AddToCart and the tree reports an error: a Server Component can't have onClick or useState, because it never runs in the browser.
  3. Props crossing the boundary must be serializable. A Server Component passes props to a client component through the payload, so they have to be data that can be written as text: strings, numbers, plain objects, arrays, dates, promises. Ordinary functions can't cross (with one exception, below).

Talking back: Server Functions

The mirror image of "use client" is "use server". It marks an async function that runs on the server but can be called from the browser, for example as a form's action:

actions.js
"use server";
export async function addToCart(formData) {
  await db.cart.add(formData.get("productId"));   // ← runs on the server
}
AddToCart.jsx
"use client";
import { addToCart } from "./actions.js";

export function AddToCart({ productId }) {
  return (
    <form action={addToCart}>                       {/* ← a server call, no fetch() written by you */}
      <input type="hidden" name="productId" value={productId} />
      <button>Add to cart</button>
    </form>
  );
}

What it costs

Staff-level§10

Islands and partial hydration

In one sentenceAn islands page is a sea of plain HTML with a few interactive islands in it. Each island hydrates by itself, and only when it's needed (on load, when it scrolls into view, on first click). Everything else ships no JavaScript at all.

Why: hydration is all-or-nothing by default

With one hydrateRoot over the whole page (§05), React must render every component in the browser before any of them works, even text that can never change. The Islands architecture idea flips the default: the page is static unless you mark a part as interactive. React Server Components (§09) reach a similar result inside React's own model; islands get there with plain HTML and several small, separate React roots.

See the difference

This article was rendered with React's real renderToString. In Hydrate whole page mode, one hydrateRoot adopts the whole article. In Islands only mode, two separate hydrateRoot calls adopt just the ♥ button (right away) and the comments box (only when you scroll down to it). Compare the render counts, then scroll the article:

Islands vs hydrating everything
Real React: a server-rendered article with two interactive widgets. Scroll the article, then compare the counts.
Component renders in the browser
0
Only the widgets get hydrated. The paragraphs stay plain HTML, and their code never needs to be downloaded. Each click on ♥ or “Me too!” adds one more render, in either mode.
Hydrated so far
  • nothing
Show the server HTML (0 chars)

The numbers come from counters inside the components. Hydrating the whole page ran 17 components in the browser: the article, 14 paragraphs, and the two widgets. With islands, the browser ran one on load and one more when the comments came into view. On a real page, the difference is the code for those paragraphs (and any libraries they use) never being downloaded at all.

How a framework spells it

Meta-frameworks built around islands make this a one-word decision per component. In Astro, for example, components render to HTML only, unless you add a client: directive:

Article.astro (Astro)
---
import LikeButton from "../components/LikeButton.jsx";
import Comments from "../components/Comments.jsx";
---
<article>
  <h1>How Neapolitan pizza is made</h1>
  <LikeButton client:load />      <!-- ← hydrate as soon as the page loads -->
  <p>Neapolitan pizza dough has four ingredients…</p>
  <Comments client:visible />     <!-- ← hydrate when it scrolls into view -->
</article>

Doing it by hand with React

Under the hood an island is just a separate root. The demo above does essentially this (it skips the dynamic import, since everything is already loaded):

islands.jsx
import { hydrateRoot } from "react-dom/client";
import { LikeButton } from "./LikeButton.jsx";

// Island 1: small and at the top, so hydrate it right away
hydrateRoot(document.querySelector('[data-island="like"]'), <LikeButton />);

// Island 2: hydrate only when it scrolls into view
const node = document.querySelector('[data-island="comments"]');
const io = new IntersectionObserver((entries) => {
  if (!entries.some((e) => e.isIntersecting)) return;
  io.disconnect();                                        // ← only once
  import("./Comments.jsx").then(({ Comments }) =>         // ← download its code only now
    hydrateRoot(node, <Comments />));
});
io.observe(node);

Islands vs Server Components

IslandsServer Components (§09)
Static partsPlain HTML, no React at allRendered by React on the server, merged by React in the browser
Interactive partsSeparate roots, each hydrated on its own scheduleClient components inside one React tree
NavigationUsually full page loads (the page is HTML)In-place updates that keep client state
Sharing state between interactive partsHard: separate roots don't share contextNormal React context within the client parts
Best forContent sites: docs, blogs, marketingApps with lots of content and app-like navigation
Practitioner§11

Choosing a strategy (per page)

In one sentenceDon't pick one strategy for the whole site. For each page, ask: is it the same for every visitor? and how fresh must it be? Shared and rarely changing → build it once. Personal or always-current → build it per request. Behind a login and app-like → CSR is fine.

The decision, interactively

Pick a strategy
Answer four questions about one page. (Pages in the same site can, and often should, use different strategies.)
Is the page the same for every visitor?
How fresh must the content be?
Must search engines and link previews see the content?
What kind of page is it?
→ SSG
Every visitor gets the same HTML and it only changes on deploy, so build it once and serve files from a CDN. Fastest and cheapest option; nothing to run per request.
Also consider: Ship little or no JavaScript: islands, or Server Components if you use React.

All of it on one table

CSRSSRSSGSSG + revalidation (ISR)Streaming SSR
HTML builtin the browserper requestat buildat build, then in the backgroundper request, in pieces
First content (§03)late (after JS + data)after the server's slowest queryfastestfastestfast parts immediately
Per-user content in the HTMLn/a (all client)yesnonoyes
Freshnesslive (fetched)liveas of the last buildwithin the revalidate windowlive
Needs a running servernoyesnoyes (or a platform that does it)yes
Cost per visit~0 (a file)a render~0~0, plus occasional rebuildsa render
Crawlers and link previewspoorgoodgoodgoodgood (wait for all data for bots, §08)

And the two techniques that sit on top of any server-rendered strategy, to cut the hydration bill (§05):

  • Server Components (§09): keep non-interactive components on the server, inside React's model.
  • Islands (§10): keep the page as plain HTML, hydrate a few widgets independently.

A worked example: one shop, five strategies

RouteWhat's specialStrategy
/ and /aboutSame for everyone, changes on deploySSG
/blog/:slugSame for everyone, mostly text, a comment boxSSG + an island (or a client component) for comments
/product/:idSame for everyone, but prices and stock change hourlySSG + revalidation (60 s), on-demand revalidation when an editor saves
/checkoutPer user, must be correct, slow payment-provider callSSR, streamed, with the slow part in a Suspense boundary
/account/* dashboardBehind a login, very interactiveCSR (or SSR for the first view only)

Notice that the cart icon appears on every one of these pages, and it's per user. That doesn't make every page SSR: the shared page stays static, and the small personal bit is filled in in the browser after load, or streamed (§08). Keeping personal data out of the shared HTML is what keeps a page cacheable.

Staff-level§12

Server rendering in production

In one sentenceOnce HTML is built on a server, the server's problems become the page's problems: who is this HTML for, who may cache it, what got baked into it, and how long did it take. Most production incidents with SSR are one of the six below.

1. A personal page in a shared cache

The worst SSR incident is also the easiest to cause. A page that says "Hi, Ada" and shows Ada's cart gets a Cache-Control: public, s-maxage=60 header because someone copied it from the product page. The CDN stores Ada's page and serves it, for the next 60 seconds, to everyone who asks for that URL.

headers.go
// Shared, same-for-everyone HTML: the CDN may keep it
w.Header().Set("Cache-Control", "public, max-age=0, s-maxage=60, stale-while-revalidate=300")

// HTML that depends on who's asking: only the user's own browser, never a shared cache
w.Header().Set("Cache-Control", "private, no-store")

The rule: if the HTML reads a Cookie or an auth header, it must not be stored by a shared cache. That's also why §11 keeps personal bits (cart count, avatar) out of the shared HTML and fills them in afterwards: it keeps the expensive part cacheable. Header semantics in depth: HTTP Caching.

2. Request data in a module-level variable

In the browser, a module-level variable belongs to one user: the one holding the tab. On a server, one process handles many users at once, and a module-level variable is shared by all of them.

✕ One variable for the whole server
let currentUser = null;                    // shared by EVERY request

app.get("/", async (req, res) => {
  currentUser = await getUser(req);        // request A sets Ada…
  const cart = await db.getCart(currentUser.id);
  // …while A awaited, request B set Bob
  res.send(renderToString(<App cart={cart} />));
});

function Header() {
  return <p>Hi {currentUser.name}</p>;     // A's page may say "Hi Bob"
}
✓ Pass it down per request
const UserContext = createContext(null);

app.get("/", async (req, res) => {
  const user = await getUser(req);         // a local: one per request
  const cart = await db.getCart(user.id);
  res.send(renderToString(
    <UserContext value={user}>
      <App cart={cart} />
    </UserContext>
  ));
});

function Header() {
  const user = use(UserContext);
  return <p>Hi {user.name}</p>;
}

The bug only shows up under concurrent load, which is why it passes every local test. Any await between setting and reading, and streaming (§08), which interleaves many renders, make it more likely. The same goes for a module-level cache keyed by nothing, or a store library created once at import time instead of once per request. Keep request-scoped data in props, context or a per-request object.

3. XSS through serialized state

§04 put the data into the page with a <script> tag. If you do it with plain JSON.stringify, a product name typed by a user can end your script early:

the attack
const dishes = [{ name: "</script><script>alert(document.cookie)</script>" }];
`<script>window.__DATA__ = ${JSON.stringify(dishes)}</script>`
// The browser's HTML parser sees </script> inside the string and closes the tag there.
// Everything after it runs as a new script: XSS.

The fix in §04, .replace(/</g, "\\u003c"), turns every < inside the JSON into the six characters \u003c. JavaScript reads \u003c inside a string as the same < character, but the HTML parser only sees a backslash and some letters, never a </script>. We ran both versions in Node 22 with the attack string above:

output (Node 22)
// plain JSON.stringify: the tag-closing text is still there
[{"name":"</script><script>alert(document.cookie)</script>"}]

// with .replace(/</g, "\\u003c"): no "<" left for the HTML parser to find
[{"name":"\u003c/script>\u003cscript>alert(document.cookie)\u003c/script>"}]

Frameworks do this for you; hand-rolled SSR often doesn't. (React itself escapes text it renders into HTML, so the same name inside an <li> comes out as &lt;/script&gt;…, which is harmless.) More on this class of bug: OWASP → XSS.

4. Rendering is CPU work on a single thread

Node runs your JavaScript on one thread (why: Async / Await → the event loop). While renderToString runs, that process can't do anything else: not accept connections, not answer a health check. We measured renderToString on this machine (production React 19.3, Node 22):

PageRender time (median)HTML size
100-row table≈ 0.5 ms6 KB
1,000-row table≈ 2.5 ms65 KB
5,000-row table≈ 7.3 ms329 KB

Those are tiny, simple components. Now do the arithmetic for a busier case: a page that costs 20 ms of CPU, at 100 requests per second, needs 100 × 20 ms = 2,000 ms of CPU every second, so two full cores just for rendering. Ways out: cache rendered HTML where it's shared (§07), render less (paginate; keep huge lists out of the HTML), and run several processes per machine. Streaming (§08) doesn't reduce the total CPU; it only sends the first bytes sooner.

5. Edge rendering and the distance to your data

Edge rendering runs your SSR code on CDN servers near the user. That shortens the trip between the user and the renderer, but if the page needs three sequential queries to a database in one region, each query now crosses an ocean. Three queries at 150 ms each is 450 ms before the first byte, worse than rendering next to the database.

Render close to your data, not close to the user, unless the data is also replicated near the user (or the page needs none). Static and cached pages (§07) are the ones that truly benefit from being at the edge.

6. Watching it: errors and timings from real users

Lab numbers like §03's tell you what's possible. What users actually get only shows up in the field (Observability covers collecting it):

  • TTFB rising means the server or its data got slower; LCP rising with flat TTFB means the page got heavier; a widening gap between LCP and when the page becomes interactive means the bundle or hydration got heavier.
  • Hydration mismatches don't break the page in production, so they're easy to miss. React fixes them by re-rendering (§06) and moves on. By default it passes the error to the browser's reportError(), so it shows up as an uncaught error in the console, with only a code: "Minified React error #418" (production builds replace React's messages with numbers to save bytes; the code links to the full text on react.dev). Monitoring tools that listen for global errors may pick it up. To report it on purpose, with the component stack, pass your own handler. Note that it replaces the default reporting:
client.jsx
hydrateRoot(document.getElementById("root"), <App dishes={window.__DATA__} />, {
  onRecoverableError(error, info) {
    // Called for hydration mismatches and other errors React recovered from by re-rendering.
    reportToMonitoring("recoverable", error.message, info.componentStack);
  },
});
  • Have a fallback. If the server render fails (onShellError in §08), serve the static CSR shell instead of an error page. The user gets a slower page instead of none; your alert tells you to fix it.
Practitioner§13

Pitfalls

Each of these comes from a rule earlier in the topic. If a server-rendered page "acts weird", check this list first.

01

“window is not defined” on the server

function Banner() {
  const wide = window.innerWidth > 800;   // ✕ crashes during server rendering
  return wide ? <BigBanner /> : <SmallBanner />;
}

There's no window, document or localStorage in Node. Code that touches them during render crashes the server render. Wrapping it in typeof window !== "undefined" stops the crash but causes a hydration mismatch instead (§06). Fix: use CSS media queries for layout differences, or read browser-only values in an effect and render a neutral version first.

02

Expecting useEffect to run on the server

Effects never run during server rendering. A component that loads its data in useEffect sends its loading state in the HTML, so the user and the crawler see "Loading…" even with SSR. Load page data on the server (pass it in as props, or with use() + Suspense, §08), and keep effects for browser-only work.

03

Fetching the same data twice

The server fetched the dishes to render the HTML, and then the browser fetches them again on hydration, because the component has a useEffect(() => fetch(…)). Twice the load, and if the data changed in between, a mismatch. Send the server's data along with the page (§04) and hydrate with it; frameworks do this for you.

04

Silencing mismatches instead of fixing them

suppressHydrationWarning everywhere makes the warnings stop, but the user keeps seeing the server's text, and attribute mismatches are never patched (§06). Use it only for a single timestamp-like element. Fix the cause everywhere else.

05

A secret in a client file

With Server Components (§09), a file marked "use client" and everything it imports are sent to the browser. Importing your database module or reading process.env.STRIPE_SECRET there puts it in the bundle, readable by anyone. Keep secrets in server-only modules (many frameworks support a server-only import that makes the build fail if a client file pulls it in) and pass only the data a component needs.

06

Caching HTML that contains user data

A shared cache (CDN) given a personalised page serves one user's page to others (§12). Personal HTML gets Cache-Control: private, no-store; shared HTML stays free of personal data.

07

Server-rendering pages that don't need it

A page that's the same for everyone and changes daily doesn't need a render per request. SSR it and you pay for servers, add a database query to every visit, and get a slower first byte than a static file would (§03). Prefer SSG or SSG + revalidation (§07) unless the page is personal or must be exactly current.

08

Forgetting the gap before hydration

SSR made the page appear sooner, so the team moved on. But on slow phones the Uncanny valley lasts seconds, and taps during it are lost (§05). Measure when the page becomes interactive, not just when it appears; make key actions real <form>s that work without JavaScript; and shrink what hydrates (§09–10).

Practitioner§14

Check yourself

Commit to an answer before you click. If you miss one, go back to the section and the live demo it points to, and make it happen in front of you.

QUESTION 1 / 6
View source on a CSR app

You open a client-rendered React app built with Vite and press Ctrl+U (view source). What's inside <body>?

QUESTION 2 / 6
Clicking too early

An SSR page has painted a Buy button, but app.js is still downloading. The user clicks it. In the simplest setup (one hydrateRoot call, no Suspense boundaries), what happens?

QUESTION 3 / 6
Spot the mismatch

The server runs in UTC. A user in Tokyo opens the page at 15:00 local time, which is 06:00 UTC. What happens during hydration?

function Greeting() {
  const hour = new Date().getHours();
  return <p>{hour < 12 ? "Good morning" : "Good afternoon"}</p>;
}
QUESTION 4 / 6
Which strategy?

A documentation site: 2,000 pages, the same for everyone, updated when the team merges a pull request.

QUESTION 5 / 6
Streaming order

A streamed page has two Suspense boundaries: Reviews (data in 1.2 s) and Recommendations (data in 2.4 s). When does the heading at the top reach the browser?

QUESTION 6 / 6
Server Component rules

In a framework that uses React Server Components, a Server Component renders <Price amount={9} />. What happens?

// Price.jsx (no "use client" at the top)
export function Price({ amount }) {
  const [currency, setCurrency] = useState("EUR");
  return <button onClick={() => setCurrency("USD")}>{amount} {currency}</button>;
}
Answered 0 / 6
Foundations§15

Cheat sheet

📄
HTML first, JS laterThe browser paints the first HTML immediately. Whatever is in it is what users, crawlers and slow phones see first.
🥡
CSREmpty div + bundle. Simple hosting, app-like after load, blank until JS and data arrive. Fine behind a login.
👨‍🍳
SSRRender per request on the server. Content sooner, per-user HTML; costs a server and a later first byte.
🧊
SSGRender once at build time, serve files from a CDN. Fastest and cheapest; same for everyone, stale until rebuilt.
🔁
ISR / revalidationA pre-built page with a max age, rebuilt in the background. Someone sees the old version once; on-demand revalidation shrinks that.
💧
HydrationhydrateRoot re-runs your components in the browser and adopts the server's DOM. Same bundle, same work, just no DOM creation.
⚖️
MismatchesFirst browser render must equal the server's. Time, locale, random, window, bad nesting break it. Fix: same inputs, or update in an effect.
🍽️
StreamingSuspense boundaries let the shell go out now and slow parts follow in the same response. Decide status codes before the shell.
🖥️
Server ComponentsRun only on the server; their code never ships. "use client" marks the boundary: that file and its imports go to the browser.
🏝️
IslandsStatic HTML plus a few separately hydrated widgets, each on its own schedule. Great for content sites.
SymptomLikely causeFix
Blank page for seconds on phonesCSR waterfall: bundle → run → fetch (§03)SSR/SSG the page, or split the bundle
Link preview / search result is emptyContent is built by JavaScript (§02)Server-render or pre-render public pages
Page looks ready, taps do nothingHydration hasn't finished (§05)Ship less JS (§09–10), real forms, streaming (§08)
"Hydration failed because the server rendered text didn't match the client"Render depends on time, locale, random or window (§06)Same inputs on both sides, or fill in after hydration
Wrong class or link stays after loadAttribute mismatch: never patched (§06)Fix the cause; it's silent in production
"window is not defined" in server logsBrowser API used during render (§13)Move it to an effect; use CSS for layout
Server-rendered page is slow to startWaiting for the slowest query (§04)Stream it: slow parts in Suspense (§08)
User sees someone else's namePersonal HTML in a shared cache, or request data in a module variable (§12)private, no-store; pass request data via props/context
Price on a static page is out of dateSSG until the next build (§07)Revalidation window + on-demand revalidation
Finished reading?Mark it complete to track your progress on a roadmap. Saved in this browser only.
Up next in Frontend & BrowserWeb Performance
Read →