Rendering strategies
Client rendering, server rendering, static generation, revalidation and streaming: what HTML arrives, when the page responds, and how to choose per page.
- 01Say what HTML arrivesTell what the first HTML holds with client rendering, server rendering, static generation, revalidation and streaming, and when JavaScript runs.
- 02Read a page load's timelineFollow the server, network and browser through one page load, and explain time to first byte, first paint, the main content, and when it responds.
- 03Choose a strategy per pageWeigh speed, freshness, search engines and server cost, and pick a strategy for each page of an app, knowing which frameworks offer which.
Two ways the same shop page can arrive
Every React page you've built in this course rendered in the browser: React ran your components there and put the result on the page. That isn't the only place it can happen. Here is a small shop page. Its result is live; click Add.
import { useState } from "react"; const products = [ { id: "tea", name: "Green tea", price: 3 }, { id: "cake", name: "Carrot cake", price: 4 },]; export default function App() { const [count, setCount] = useState(0); return ( <main> <h1>Shop</h1> <p>Cart: {count}</p> <ul> {products.map((product) => ( <li key={product.id}> {product.name}: ${product.price} <button onClick={() => setCount(count + 1)}>Add</button> </li> ))} </ul> </main> );}Lines 3 to 6 are the products. Line 9 keeps the cart's count in state, and line 17 makes one list item per product, each with a button that adds 1. Nothing new. What this lesson asks is: where and when do these components turn into HTML, and what does the browser get first? With a plain Vite app, the server sends this:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Shop</title>
<script type="module" crossorigin src="/assets/index-B7xQ2k.js"></script>
</head>
<body>
<div id="root"></div>
</body>
</html>Line 9 is the whole body: an empty <div id="root">. Line 6 loads the JavaScript. Until that file has downloaded and run, the page is blank. A server can instead run the components itself and send what they show:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Shop</title>
<script type="module" crossorigin src="/assets/index-B7xQ2k.js"></script>
</head>
<body>
<div id="root"><main><h1>Shop</h1><p>Cart: <!-- -->0</p><ul><li>Green tea<!-- -->: $<!-- -->3<!-- --> <button>Add</button></li><li>Carrot cake<!-- -->: $<!-- -->4<!-- --> <button>Add</button></li></ul></main></div>
</body>
</html>Line 9 now holds the shop: the heading, the count, and both products, as plain HTML. The browser can show it as soon as it arrives. This is the real output of React's server renderer for the component above. The <!-- --> comments mark where one piece of text ends and the next begins, so React in the browser can match them later. The buttons are there, but they don't work until the same JavaScript has loaded.
<div id="root">, so the page stays blank until the JavaScript runs. With server-side rendering the products are already in the HTML.You can get lunch five ways. A meal kit arrives fast, but you cook before you eat. A restaurant cooks for you while you wait. A sandwich from the shelf is instant, but it was made this morning. A shop that restocks the shelf every hour keeps it fresher. And a restaurant can bring the soup while the main course is still cooking.
- In real life: A meal kit: you cook it at home
- In React: Client-side rendering: the browser renders the page
- In real life: A restaurant that cooks each order
- In React: Server-side rendering: the server renders each request
- In real life: Sandwiches made in the morning, ready on the shelf
- In React: Static generation: HTML rendered at build time
- In real life: The shelf restocked every hour with fresh sandwiches
- In React: Revalidation: static HTML rebuilt now and then
- In real life: A meal served course by course, as each is ready
- In React: Streaming: HTML sent in pieces as data arrives
A rendering strategy decides where and when your components first turn into HTML: in the browser after the JavaScript loads (client-side rendering), on a server for each request (server-side rendering), once at build time (static generation), at build time and again now and then (revalidation), or on the server in pieces as the data arrives (streaming). The components are the same. What changes is what the first HTML holds, how soon the page shows, how soon it responds, and how much work your servers do.
Client-side rendering: the browser builds the page
With client-side rendering (CSR), the server sends the empty HTML and the JavaScript, and the browser does everything else. Data comes last, because the code that asks for it is in that JavaScript. Here, fetchProducts stands in for a request to a server that takes 1.5 seconds. Press Run again under the result to watch it load again.
import { useEffect, useState } from "react"; function fetchProducts() { return new Promise((resolve) => { const products = [ { id: "tea", name: "Green tea", price: 3 }, { id: "cake", name: "Carrot cake", price: 4 }, ]; setTimeout(() => resolve(products), 1500); });} export default function App() { const [products, setProducts] = useState(null); useEffect(() => { fetchProducts().then(setProducts); }, []); if (!products) return <p>Loading products…</p>; return ( <ul> {products.map((product) => ( <li key={product.id}> {product.name}: ${product.price} </li> ))} </ul> );}Lines 3 to 11 fake a server: a promise that gives the products after 1500 ms. Line 14 starts with no products, so line 20 shows Loading products…. Lines 16 to 18 are an Effect that asks for the data after the first render (Fetching data covers this pattern and its pitfalls). When the answer arrives, setProducts stores it, and lines 21 to 29 show the list.
Here is the whole page load as three lanes, server, network and browser, using the lesson's model of a phone on a fast connection:
Loading…), and only then asks for the data. The products appear at 1390 ms in the lesson's model.Client-side rendering, in short:
- The first HTML is empty. Something shows at 460 ms, after the JavaScript runs, and the products at 1390 ms, after a second trip for the data.
- Search engines and link previews see little. Google runs JavaScript, but in a later pass; many other crawlers, and the link previews in chat apps, read only the HTML.
- It's cheap and simple to host. The server only sends files, which a CDN (a network of servers close to your visitors) can do.
- After the first load, moving between pages is fast, because the app is already running in the browser (Routing shows how).
Server-side rendering: HTML for every request
With server-side rendering (SSR), a server runs your components for each request and sends the HTML they describe. React does this with react-dom/server. Its simplest function, renderToString, takes JSX and returns HTML as text. It runs in a browser too, so you can watch it here:
import { renderToString } from "react-dom/server"; function ProductList({ products }) { return ( <ul> {products.map((product) => ( <li key={product.id}>{`${product.name}: $${product.price}`}</li> ))} </ul> );} const products = [ { id: "tea", name: "Green tea", price: 3 }, { id: "cake", name: "Carrot cake", price: 4 },]; // On a real server, this line runs for every request.const html = renderToString(<ProductList products={products} />); export default function App() { return <pre style={{ whiteSpace: "pre-wrap" }}>{html}</pre>;}Lines 3 to 11 are an ordinary component. Line 19 calls renderToString with it, and the result shows the text it returns: <ul><li>Green tea: $3</li>…. On a server, that text goes inside the page's <div id="root">, as in the overview. Press Edit & run and add a third product on line 15: the HTML grows.
HTML can't respond to clicks. To make the page interactive, the browser loads the same JavaScript, and React runs the components again and connects them to the HTML that's already there. That step is hydration. Until it's done, the page looks ready but isn't. This demo has no server, so line 8 makes the server's HTML itself; line 12 hydrates it 3 seconds later, the way a slow script would arrive. Click Add to cart right away, then again after the sentence changes:
import { hydrateRoot } from "react-dom/client";import { renderToString } from "react-dom/server";import App from "./App.jsx"; const root = document.getElementById("root"); // 1. The server's HTML arrives: the page shows, but nothing responds.root.innerHTML = renderToString(<App />); // 2. Three seconds later the JavaScript has loaded, and React takes over.setTimeout(() => { hydrateRoot(root, <App />);}, 3000);Line 8 fills the root with the HTML that App renders: you see the price and the button at once. Line 12, hydrateRoot, runs after 3 seconds and takes over that HTML. Before then, nothing listens for clicks, so they're lost: the count stays at 0. In the App.jsx tab, line 8 runs only in the browser after hydration, so the sentence on line 15 changes from HTML only: clicks do nothing yet. to JavaScript has run: clicks work. The lesson Server rendering and hydration explains hydrateRoot, and what happens when the server's HTML and the browser's render don't match.
Server-side rendering, in short:
- The first HTML has the content, so the products show at 960 ms in the lesson's model, and search engines read them.
- The first byte waits for the data. The server loads the products before it sends anything: 930 ms here, and slower data makes it later.
- The page responds only after hydration, at 1340 ms: the gap you clicked into.
- Every visit costs server work: loading data and rendering, for every request.
Static generation: HTML built ahead of time
If a page is the same for every visitor, there's no need to render it for each one. With static generation (SSG), you render every page once, when you build the site, and save the HTML as files. React 19 added prerender, in react-dom/static, for this: unlike renderToString, it waits for data that Suspense is waiting on, so the files are complete. Frameworks run a build step like this for you; written by hand it looks like this:
import { writeFile } from "node:fs/promises";
import { prerender } from "react-dom/static";
import { ProductPage } from "./ProductPage.js";
import { loadProducts } from "./data.js";
const products = await loadProducts();
for (const product of products) {
const { prelude } = await prerender(<ProductPage product={product} />);
const html = await new Response(prelude).text();
await writeFile(`dist/products/${product.id}.html`, html);
}Line 6 loads the products once, at build time. Lines 8 to 12 render ProductPage for each one: line 9 calls prerender, line 10 reads its result as text, and line 11 writes a file. This is the component it renders:
export function ProductPage({ product }) {
return (
<main>
<h1>{product.name}</h1>
<p>{`$${product.price}`}</p>
</main>
);
}With the shop's two products, the build writes dist/products/tea.html, holding <main><h1>Green tea</h1><p>$3</p></main>, and dist/products/cake.html. A CDN then sends those files as they are.
Static generation, in short:
- It's the fastest first response: a file from a server near the visitor, with the products in it, at 80 ms, whatever the data costs.
- It's cheap: no rendering per visit.
- The data is as old as the build. A new price needs a new build, and a site with a million products takes long to build.
- It can't differ per visitor. Anything personal, like a signed-in name, is added by the browser after hydration.
Revalidation: static pages that refresh themselves
Revalidation keeps static pages fresh without rebuilding the whole site. Next.js calls it Incremental Static Regeneration (ISR). Here is how a Next.js page asks for it (Next.js 16, checked in October 2026; Next.js's newer Cache Components option sets this up differently). A page says how many seconds its HTML stays fresh:
// app/products/[id]/page.jsx in a Next.js app
export const revalidate = 60;
export async function generateStaticParams() {
const products = await db.products.all();
return products.map((product) => ({ id: product.id }));
}
export default async function ProductPage({ params }) {
const { id } = await params;
const product = await db.products.find(id);
return <h1>{product.name}: ${product.price}</h1>;
}Line 2 exports revalidate = 60. Lines 4 to 7 list the pages to build, one per product, like the build script above (db stands for your database code). Lines 9 to 13 are the page: an async component that reads one product from the database. Next.js renders each page, saves the HTML, and sends the saved copy. Once the copy is older than 60 seconds, the next visitor still gets it, and a rebuild starts in the background. Visitors after the rebuild get the new HTML. Nobody waits for a rebuild. Try it: open the page, raise the price, then wait and open the page again.
0 s$3, 0 s old$31 event- 0 s The page is built: it says $3.
The page is built: it says $3.
A price that changes at 20 s shows up only after the first visit after 60 s, and that visit still sees the old price. If a change must show at once, revalidate from the code that makes the change:
"use server";
import { revalidatePath } from "next/cache";
export async function updatePrice(id, price) {
await db.products.update(id, { price });
revalidatePath(`/products/${id}`);
}Line 1 makes this a Server Function, which a form can call (Form actions; a later lesson, Server Functions, covers them). Line 5 saves the price, and line 6 tells Next.js that the saved page for this product is out of date, so the next visitor gets a fresh one.
Streaming: the page in pieces
Server-side rendering waits for all the data before it sends a byte. Streaming sends the page in pieces instead: first everything that's ready, with a Suspense fallback where a slow part goes, then that part when its data arrives. React's renderToReadableStream does it. This demo runs it in the browser and lists each piece of HTML as it arrives:
import { Suspense, use, useEffect, useState } from "react";import { renderToReadableStream } from "react-dom/server"; const reviews = new Promise((resolve) => { setTimeout(() => resolve(["Fresh and light", "Lovely with honey"]), 2000);}); function Reviews() { const list = use(reviews); return <ul>{list.map((text) => <li key={text}>{text}</li>)}</ul>;} function ProductPage() { return ( <main> <h1>Green tea: $3</h1> <Suspense fallback={<p>Loading reviews…</p>}> <Reviews /> </Suspense> </main> );} export default function App() { const [pieces, setPieces] = useState([]); useEffect(() => { let active = true; const start = performance.now(); renderToReadableStream(<ProductPage />).then(async (stream) => { const reader = stream.pipeThrough(new TextDecoderStream()).getReader(); while (true) { const { done, value } = await reader.read(); if (done || !active) return; const ms = Math.round(performance.now() - start); setPieces((list) => [...list, { ms, html: value }]); } }); return () => { active = false; }; }, []); return ( <ol> {pieces.map((piece, index) => ( <li key={index}> after {piece.ms} ms: <code style={{ wordBreak: "break-all" }}>{piece.html}</code> </li> ))} </ol> );}Lines 4 to 6 are reviews that take 2 seconds. Line 9 reads them with use, which suspends Reviews until they're ready. Lines 17 to 19 wrap it in Suspense with a fallback. Line 30 starts the stream, and lines 32 to 37 read it piece by piece and add each one, with its time, to the list.
The first piece arrives at once. It holds the heading and Loading reviews…, marked with a <template id="B:0">, plus a small script. About 2 seconds later a second piece arrives: the reviews in a hidden <div id="S:0">, and a script that moves them in place of the fallback. A browser shows the first piece while the server is still loading the reviews. The lesson Streaming server rendering covers how it works and how hydration happens piece by piece.
Server Components, at a glance
So far, every strategy sends the JavaScript for every component, because the browser runs them all again to hydrate. Server Components are components that run only on the server, or at build time. They can read a database directly, and their code is never sent to the browser: only what they rendered is. Components that need state or clicks are marked "use client" and work as before.
// ProductPage.jsx: a Server Component. It runs on the server only.
import { db } from "./db.js";
import AddToCart from "./AddToCart.jsx";
export default async function ProductPage({ id }) {
const product = await db.products.find(id);
return (
<main>
<h1>{product.name}: ${product.price}</h1>
<AddToCart id={product.id} />
</main>
);
}// AddToCart.jsx: a Client Component. Its code is sent to the browser.
"use client";
import { useState } from "react";
export default function AddToCart({ id }) {
const [added, setAdded] = useState(false);
return <button onClick={() => setAdded(true)}>{added ? "Added" : "Add to cart"}</button>;
}In ProductPage.jsx, line 5 is an async component, and line 6 reads the product straight from the database: no API, no Effect. Line 10 uses AddToCart. In AddToCart.jsx, line 2, "use client", marks it as a Client Component: its code goes to the browser, and it hydrates. ProductPage's code doesn't.
Server Components need a framework that supports them, like Next.js's App Router, and they usually work together with streaming server rendering for the first HTML. In this lesson's model they send 120 kB of JavaScript instead of 200 kB. The lesson Server Components goes through them properly.
Every strategy on one timeline
A few numbers describe how a page load feels. How to read the table: each row is one number, what it measures, and what makes it smaller.
| Number | What it measures | What makes it smaller |
|---|---|---|
| Time to first byte (TTFB) | From asking for the page to the first byte of HTML arriving. | Files a CDN already has (SSG, ISR), and streaming, which sends the shell before the data is ready. |
| First contentful paint (FCP) | When the browser first shows anything, like a header or Loading…. | HTML that arrives with content in it: every strategy except client-side rendering. |
| Largest contentful paint (LCP) | When the main content, here the products, shows. It's one of Google's Core Web Vitals. | HTML built ahead of time (SSG, ISR); then server rendering and streaming. |
| When it responds | When a click on Add to cart works. Older tools called it time to interactive (TTI); Google now measures responsiveness with INP. | Less JavaScript to download and run, and HTML that arrives early so hydration can start sooner. |
The playground runs one product page load with each strategy. Pick a strategy and watch its lanes; the table under the lanes compares all six with the same settings. Then press Data: 2000 ms: server rendering gets 1200 ms slower to the first byte, static generation doesn't move, and streaming still shows its shell at 140 ms. Then try Slow connection with Streaming and RSC: less JavaScript means the page responds sooner.
first HTML: an empty rootThe dashed lines mark the first byte, when the products show, and when Add to cart responds.
| Strategy | First byte | First paint | Products | Responds |
|---|---|---|---|---|
| CSR | 50 | 460 | 1390 | 1390 |
| SSR | 930 | 960 | 960 | 1340 |
| SSG | 50 | 80 | 80 | 460 |
| ISR | 50 | 80 | 80 | 460 |
| Streaming | 110 | 140 | 960 | 980 |
| RSC | 110 | 140 | 960 | 980 |
The first byte comes after 50 ms, but the HTML is empty. The page shows Loading… at 460 ms, once the JavaScript has run, and the products at 1390 ms, after the browser has fetched the data. They respond as soon as they appear.
How to read the table: each row is a strategy, what its first HTML holds, how fresh its data is, and what kind of page it suits.
| Strategy | The first HTML | Data as fresh as | Good for |
|---|---|---|---|
| Client-side rendering (CSR) | An empty root. The browser renders everything. | Each visit: the browser fetches it. | Pages behind a login, like dashboards, where search engines don't matter. |
| Server-side rendering (SSR) | The whole page, rendered for this request. | Each visit: the server loads it. | Pages that differ per visitor or per search, and that search engines must read. |
| Static generation (SSG) | The whole page, rendered at build time. | The last build. | Pages that are the same for everyone and rarely change: about pages, docs, blog posts. |
| Static + revalidate (ISR) | The whole page, saved and rebuilt now and then. | The last rebuild: about the revalidate time. | Pages that are the same for everyone but change during the day: product pages, news. |
| Streaming SSR | The shell first, then each part as its data is ready. | Each visit. | Server-rendered pages with a slow part, like reviews or recommendations. |
| Server Components (RSC) | Like streaming, and the Server Components send no code. | Each visit, or the build. | Pages with lots of content to show and few parts to click. |
A strategy for each page of a shop
You don't pick one strategy for a whole app. A shop's About page, its product pages, its search and its account pages have different needs, and frameworks let you choose per page (per route). Three questions decide most cases:
- Is the page the same for everyone? If not, it can't be built ahead of time.
- How often does it change? Rarely: static. During the day: static with revalidation.
- Must search engines and link previews read it? If so, send HTML with content: render on the server.
How to read the table: each row is a tool, and the rendering it offers (checked against each project's documentation, October 2026).
| Tool | Rendering it offers |
|---|---|
| Vite with React | Client-side rendering. The build makes static files, and the browser renders every page. |
| Next.js (App Router) | Static generation, server rendering, revalidation, streaming with Suspense, and Server Components, chosen per route. react.dev calls it the most complete Server Components setup. |
| React Router (framework mode) | Client-side rendering (SPA mode), server rendering, static pre-rendering of the URLs you list, and streaming with Suspense. Its Server Components support is experimental. |
| TanStack Start | Full-document server rendering and streaming, with server functions. react.dev lists it among the up-and-coming frameworks. |
| Astro | Static HTML by default, server rendering when you turn it on. React components render to HTML, and only the ones you mark get JavaScript. |
React's own docs recommend starting a new app with a framework, and note that the frameworks they recommend can all build static, client-only apps and add server rendering per route later (Setting up a React project). Sort these pages with the three questions:
- An About us page, the same for everyone, edited twice a year
- A blog post that doesn't change after it's published
- A product page whose price changes a few times a day, found through search engines
- A news front page that changes every few minutes, the same for everyone
- Search results for whatever the visitor typed, which search engines should read
- A product page that shows each signed-in visitor their own price, and must be found in search
- An admin dashboard behind a login
- A drawing tool where everything happens in the browser
Ask: is it the same for everyone, how often does it change, and must search engines read it? Then sort each page.
Common mix-ups
- "Server-side rendering means less JavaScript." No. The browser still downloads every component to hydrate the page. Server Components are what send less.
- "Once the page shows, it works." No. A server-rendered page shows before it's hydrated, and clicks before hydration do nothing.
- "Static means the page can't change." No. The HTML is fixed until the next build or revalidation, but after hydration it's a normal React app with state, clicks and data from the browser.
- "Revalidate = 60 means visitors see changes within 60 seconds." Not quite. The first visit after 60 seconds still gets the old page and starts the rebuild. Without visitors, nothing rebuilds.
- "Client-side rendering is outdated." No. For apps behind a login it's simple, cheap, and fast after the first load. It's a choice, not a mistake.
- "Server Components replace server-side rendering." No. They decide which components run only on the server; streaming server rendering still turns the result into the first HTML.
Practice exercises
Some exercises show their result under the code; the ones that ask you to predict show it when you press Edit & run. Guess first, then check.
This is the page from the client-side rendering section. What does it show for the first 1.5 seconds?
import { useEffect, useState } from "react";
function fetchProducts() {
return new Promise((resolve) => {
const products = [
{ id: "tea", name: "Green tea", price: 3 },
{ id: "cake", name: "Carrot cake", price: 4 },
];
setTimeout(() => resolve(products), 1500);
});
}
export default function App() {
const [products, setProducts] = useState(null);
useEffect(() => {
fetchProducts().then(setProducts);
}, []);
if (!products) return <p>Loading products…</p>;
return (
<ul>
{products.map((product) => (
<li key={product.id}>
{product.name}: ${product.price}
</li>
))}
</ul>
);
}import { useEffect, useState } from "react"; function fetchProducts() { return new Promise((resolve) => { const products = [ { id: "tea", name: "Green tea", price: 3 }, { id: "cake", name: "Carrot cake", price: 4 }, ]; setTimeout(() => resolve(products), 1500); });} export default function App() { const [products, setProducts] = useState(null); useEffect(() => { fetchProducts().then(setProducts); }, []); if (!products) return <p>Loading products…</p>; return ( <ul> {products.map((product) => ( <li key={product.id}> {product.name}: ${product.price} </li> ))} </ul> );}For 1.5 seconds products is null, so line 20 shows Loading products…. With client-side rendering, that's the first thing anyone sees, and only after the JavaScript has run. The products appear when the fake request answers.
What string does renderToString return on line 7?
import { renderToString } from "react-dom/server";
function Price({ amount }) {
return <strong>{`$${amount}`}</strong>;
}
const html = renderToString(<Price amount={3} />);
export default function App() {
return <pre>{html}</pre>;
}import { renderToString } from "react-dom/server"; function Price({ amount }) { return <strong>{`$${amount}`}</strong>;} const html = renderToString(<Price amount={3} />); export default function App() { return <pre>{html}</pre>;}Price returns a <strong> holding one string, so the HTML is <strong>$3</strong>. A server would put this text into the page it sends.
Press Run again under the result. Click Add to cart twice before the sentence changes, then once after. What does the button say?
import { hydrateRoot } from "react-dom/client";import { renderToString } from "react-dom/server";import App from "./App.jsx"; const root = document.getElementById("root"); // 1. The server's HTML arrives: the page shows, but nothing responds.root.innerHTML = renderToString(<App />); // 2. Three seconds later the JavaScript has loaded, and React takes over.setTimeout(() => { hydrateRoot(root, <App />);}, 3000);import { hydrateRoot } from "react-dom/client";import { renderToString } from "react-dom/server";import App from "./App.jsx"; const root = document.getElementById("root"); // 1. The server's HTML arrives: the page shows, but nothing responds.root.innerHTML = renderToString(<App />); // 2. Three seconds later the JavaScript has loaded, and React takes over.setTimeout(() => { hydrateRoot(root, <App />);}, 3000);The two early clicks hit plain HTML, with no listener, so they're lost. After hydrateRoot on line 12, React handles the click, and the button says Add to cart (1). Change 3000 to 0 on line 13 to see a page that hydrates at once.
Here the whole page arrives as one piece, after 2 seconds. Add a Suspense boundary so the heading arrives first. With your fix, what does the first piece show where the reviews will go?
import { Suspense, use, useEffect, useState } from "react";import { renderToReadableStream } from "react-dom/server"; const reviews = new Promise((resolve) => { setTimeout(() => resolve(["Fresh and light", "Lovely with honey"]), 2000);}); function Reviews() { const list = use(reviews); return <ul>{list.map((text) => <li key={text}>{text}</li>)}</ul>;} function ProductPage() { return ( <main> <h1>Green tea: $3</h1> <Reviews /> </main> );} export default function App() { const [pieces, setPieces] = useState([]); useEffect(() => { let active = true; const start = performance.now(); renderToReadableStream(<ProductPage />).then(async (stream) => { const reader = stream.pipeThrough(new TextDecoderStream()).getReader(); while (true) { const { done, value } = await reader.read(); if (done || !active) return; const ms = Math.round(performance.now() - start); setPieces((list) => [...list, { ms, html: value }]); } }); return () => { active = false; }; }, []); return ( <ol> {pieces.map((piece, index) => ( <li key={index}> after {piece.ms} ms: <code style={{ wordBreak: "break-all" }}>{piece.html}</code> </li> ))} </ol> );}import { Suspense, use, useEffect, useState } from "react";import { renderToReadableStream } from "react-dom/server"; const reviews = new Promise((resolve) => { setTimeout(() => resolve(["Fresh and light", "Lovely with honey"]), 2000);}); function Reviews() { const list = use(reviews); return <ul>{list.map((text) => <li key={text}>{text}</li>)}</ul>;} function ProductPage() { return ( <main> <h1>Green tea: $3</h1> <Suspense fallback={<p>Loading reviews…</p>}> <Reviews /> </Suspense> </main> );} export default function App() { const [pieces, setPieces] = useState([]); useEffect(() => { let active = true; const start = performance.now(); renderToReadableStream(<ProductPage />).then(async (stream) => { const reader = stream.pipeThrough(new TextDecoderStream()).getReader(); while (true) { const { done, value } = await reader.read(); if (done || !active) return; const ms = Math.round(performance.now() - start); setPieces((list) => [...list, { ms, html: value }]); } }); return () => { active = false; }; }, []); return ( <ol> {pieces.map((piece, index) => ( <li key={index}> after {piece.ms} ms: <code style={{ wordBreak: "break-all" }}>{piece.html}</code> </li> ))} </ol> );}Without a boundary, React waits for the reviews and sends the whole page as one piece, after 2 seconds. With Suspense around Reviews, the first piece is sent at once, with Loading reviews… where the reviews will go.
chooseStrategy always says CSR. Make it follow the three questions from the practical section. What does it print for the product page?
function chooseStrategy(page) {
return "CSR";
}
const pages = [
{ name: "About us", sameForEveryone: true, changes: "rarely", needsSearch: true },
{ name: "Product page", sameForEveryone: true, changes: "hourly", needsSearch: true },
{ name: "Order history", sameForEveryone: false, changes: "often", needsSearch: false },
{ name: "Search results", sameForEveryone: false, changes: "often", needsSearch: true },
];
for (const page of pages) console.log(`${page.name}: ${chooseStrategy(page)}`);function chooseStrategy(page) {
if (page.sameForEveryone && page.changes === "rarely") return "SSG";
if (page.sameForEveryone) return "ISR";
if (page.needsSearch) return "SSR";
return "CSR";
}
const pages = [
{ name: "About us", sameForEveryone: true, changes: "rarely", needsSearch: true },
{ name: "Product page", sameForEveryone: true, changes: "hourly", needsSearch: true },
{ name: "Order history", sameForEveryone: false, changes: "often", needsSearch: false },
{ name: "Search results", sameForEveryone: false, changes: "often", needsSearch: true },
];
for (const page of pages) console.log(`${page.name}: ${chooseStrategy(page)}`);Pages that are the same for everyone can be built ahead of time: SSG if they rarely change, ISR if they change during the day. Pages that differ per visitor are rendered per request when search engines need them, and in the browser otherwise. It prints About us: SSG, Product page: ISR, Order history: CSR, Search results: SSR.
This is the revalidation playground's model in plain JavaScript. Without running it, what does it print for the visitor at 66 s? Then press Edit & run.
const revalidate = 60; // seconds the saved page stays fresh
const rebuild = 2; // seconds a rebuild takes
let saved = { price: 3, builtAt: 0 };
let database = 3;
let rebuilding = null;
function at(time, what) {
if (rebuilding && rebuilding.doneAt <= time) {
saved = { price: rebuilding.price, builtAt: rebuilding.doneAt };
rebuilding = null;
}
if (what === "change") {
database += 1;
return;
}
const stale = time - saved.builtAt >= revalidate;
if (stale && !rebuilding) rebuilding = { doneAt: time + rebuild, price: database };
console.log(`${time} s: $${saved.price}${stale ? " (stale)" : ""}`);
}
at(10, "change");
at(50, "visit");
at(65, "visit");
at(66, "visit");
at(70, "visit");const revalidate = 60; // seconds the saved page stays fresh
const rebuild = 2; // seconds a rebuild takes
let saved = { price: 3, builtAt: 0 };
let database = 3;
let rebuilding = null;
function at(time, what) {
if (rebuilding && rebuilding.doneAt <= time) {
saved = { price: rebuilding.price, builtAt: rebuilding.doneAt };
rebuilding = null;
}
if (what === "change") {
database += 1;
return;
}
const stale = time - saved.builtAt >= revalidate;
if (stale && !rebuilding) rebuilding = { doneAt: time + rebuild, price: database };
console.log(`${time} s: $${saved.price}${stale ? " (stale)" : ""}`);
}
at(10, "change");
at(50, "visit");
at(65, "visit");
at(66, "visit");
at(70, "visit");The price changes at 10 s. At 50 s the page is fresh: $3. At 65 s it's stale: $3, and a rebuild starts that ends at 67 s with $4. At 66 s the rebuild is still running, so: 66 s: $3 (stale). At 70 s the new page is 3 s old: 70 s: $4.
Check your understanding
For each question, ask where the HTML is made, when, and what the browser has to wait for: the server, the data, or the JavaScript.
Question 1 of 7With client-side rendering, what is inside
<div id="root">in the first HTML the browser gets?Choose an answer to see the explanation.
Question 2 of 7The products take 2 seconds to load from the database. Which number grows by about 2 seconds with server-side rendering, but not with static generation?
Choose an answer to see the explanation.
Question 3 of 7What does this server code print?
Read the code, then predictimport { renderToString } from "react-dom/server"; function CartBadge({ count }) { return <span>{`${count} items`}</span>; } console.log(renderToString(<CartBadge count={2} />));Choose an answer to see the explanation.
Question 4 of 7A page uses
revalidate = 60. It was built at 0 s with $3; the price changed to $4 at 30 s. Visitor A opens it at 70 s, and a rebuild takes 2 s. What does A see?Choose an answer to see the explanation.
Question 5 of 7When does streaming server rendering help most?
Choose an answer to see the explanation.
Question 6 of 7What does a Server Component send to the browser?
Choose an answer to see the explanation.
Question 7 of 7Why does a server-rendered Add to cart button sometimes do nothing for a moment after it appears?
Choose an answer to see the explanation.
Key takeaways
- Client-side rendering sends an empty root; the browser runs the JavaScript, then fetches the data. Simple and cheap, slow to first content.
- Server-side rendering sends the page's HTML for each request. The first byte waits for the data, and the page responds only after hydration.
- Static generation renders at build time, so a CDN sends finished HTML fast; revalidation rebuilds it in the background after a set time.
- Streaming sends the ready parts first and the slow parts later, with Suspense fallbacks in between.
- Server Components run only on the server and send their result, not their code, so the browser downloads less JavaScript.
- Choose per page: same for everyone or not, how often it changes, and whether search engines must read it.
Remember the one-liner.
The same components can turn into HTML in the browser, on the server per request, or ahead of time; choose for each page by what it must wait for.
Coming next: Server rendering and hydration. It shows how renderToString and hydrateRoot work together, and how to fix the page when the server and the browser disagree.