Server Rendering and Server Components

Everything so far has assumed the same starting point: the browser downloads an empty <div id="root">, then your JavaScript, and then React builds the page. For a dashboard behind a login that is the right answer. For a public catalog it is a liability — the first paint waits on a bundle, and the text that search engines and link previews want is not in the document they fetch.Server rendering moves the first render to Node. The server calls your components, turns the resulting element tree into HTML, and sends that HTML in the response; the browser paints it immediately and then hydrates it, attaching the event handlers that make it interactive. React Server Components go further and let some components run only on the server, never shipping their code or their dependencies to the browser at all.This section shows both from the bottom: a hand-rolled server, the streaming API that replaced renderToString, the static prerender that produces files for a CDN, and the model behind Server Components. Frameworks own this layer in practice, but they call exactly these functions, and Chapter 5 puts the practical version to work in Next.js.

2.20.1 Why Render on the Server

A client-rendered page arrives empty. The browser parses a document whose body is one div, discovers a script, downloads it, parses it, runs it, and only then does React produce elements — nothing is on screen until that chain finishes, and it is as slow as the reader's connection and phone. A server-rendered page arrives with its markup in the response, so the browser paints on the first parse and the bundle catches up afterwards.Figure 2.63: Client versus server rendering on one connection; grey is work done on the serverBe precise about what that buys. The page paints sooner, which is what a reader on a slow phone notices and what Largest Contentful Paint measures. It becomes interactive slightly later, because hydration is work a client-only page never does. Content sits in the HTML, so crawlers, link unfurlers and text-only clients see it without running scripts. And data can be fetched inside your data center instead of over the reader's connection.The costs are equally concrete. You run a Node process that must stay up, and rendering is CPU work that scales with traffic: a component blocking for 300 ms blocks that request. Your components must survive an environment with no window, no document and no localStorage, so code touching them moves into an Effect or behind a guard. Session-specific rendering fights with caching. And you ship components twice, as HTML and as the bundle that hydrates them. Note: Server rendering does not reduce how much JavaScript you send. The bundle for the catalog in this section is 219 KB minified, 68 KB over the wire with gzip, whether or not the server renders first. Only Server Components (Section 2.20.5) remove component code from the download.

2.20.2 renderToString and hydrateRoot

The smallest complete server-rendered application is a component, a server, a browser entry and no framework. app.jsx exports an ordinary App that takes a products prop and renders <h1>Catalog ({products.length} items)</h1>, a <ul> of names and prices, and a button whose onClick increments a useState counter. renderToString turns it into HTML synchronously; the rest of the server is plumbing — serve the bundle, embed the data the render used (data.js exports the two-product array), and link the bundle.

import { createServer } from 'node:http';

import { readFile } from 'node:fs/promises';

import { renderToString } from 'react-dom/server';

import App from './app.jsx';

import { products } from './data.js';

createServer(async (req, res) => {

if (req.url === '/client.js') {

res.writeHead(200, { 'Content-Type': 'text/javascript' });

return res.end(await readFile('./public/client.js'));

}

const html = renderToString(<App products={products} />);

const data = JSON.stringify(products).replace(/</g, '\\u003c');

res.writeHead(200, { 'Content-Type': 'text/html' });

res.end(`<!doctype html><html><head><title>Catalog</title></head><body>

<div id="root">${html}</div><script id="data" type="application/json">${data}</script>

<script type="module" src="/client.js"></script></body></html>`);

}).listen(4000, () => console.log('SSR server on http://localhost:4000/'));

Listing 2.190: server.jsx - a hand-rolled SSR server on node:http The browser entry parses the embedded JSON and calls hydrateRoot instead of createRoot, with the props the server used. That is the rule that matters: render a different tree than the server did and React throws the markup away, re-renders on the client and logs a hydration mismatch — which is why timestamps, Math.random() and window checks belong in an Effect, not in the render.

import { hydrateRoot } from 'react-dom/client';

import App from './app.jsx';

const products = JSON.parse(document.getElementById('data').textContent);

hydrateRoot(document.getElementById('root'), <App products={products} />);

Listing 2.191: client.jsx, then the commands that build and run all of it

echo '{ "compilerOptions": { "jsx": "react-jsx", "allowJs": true } }' > tsconfig.json

npx esbuild client.jsx --bundle --minify --format=esm --outfile=public/client.js

node --import tsx server.jsx

Listing 2.192: Building the client bundle and starting the server Node cannot parse JSX and browsers cannot resolve bare imports, so tsx compiles the server files as Node loads them and esbuild bundles the client entry; both read jsx from tsconfig.json, which must also allow .jsx files. What curl http://localhost:4000/ returns is a finished page, not a shell:

<!doctype html><html><head><title>Catalog</title></head><body>

<div id="root"><main><h1>Catalog (<!-- -->2<!-- --> items)</h1><ul><li>Keyboard<!-- --> - $<!--

-->79</li><li>Mouse<!-- --> - $<!-- -->29</li></ul><button>In cart: <!-- -->0</button></main>

</div><script id="data" type="application/json">[{"id":1,"name":"Keyboard","price":79},...]

</script><script type="module" src="/client.js"></script></body></html>

Output of Listing 2.192 Under the hood: Those <!-- --> comments are not noise. Catalog (, 2 and items) are three separate text children; without a separator the browser would parse them as one text node and hydration could not line the tree up with the DOM, so React inserts an empty comment between adjacent text children. The button carries no onclick attribute either — handlers exist only after hydration. Hydration is not a second render into an empty container: React walks the existing DOM, matches it against the tree your components produce, and attaches listeners to nodes already there. In Chrome 152 the page paints the catalog before the bundle arrives, and clicking the button twice afterwards shows "In cart: 2" with nothing logged to the console. Disable JavaScript and the list stays readable; only the button stops working.renderToStaticMarkup is the same function without the hydration markers, for output nothing will hydrate: email bodies, RSS descriptions, a PDF pipeline. Note what renderToString cannot do, though: it is synchronous, so a Suspense boundary that suspends renders its fallback and never resolves. React's documentation labels it legacy for that reason.

2.20.3 Streaming with renderToPipeableStream

renderToString makes the reader wait for the slowest part of the page. renderToPipeableStream does not: it sends the shell — everything outside your Suspense boundaries — as soon as it is ready, keeps the connection open, and pushes each boundary's markup down the same response when its data arrives. React renders the whole document here, <html> included, and bootstrapModules injects the hydration script.

import { createServer } from 'node:http';

import { Suspense, use } from 'react';

import { renderToPipeableStream } from 'react-dom/server';

const reviews = () =>

new Promise((ok) => setTimeout(() => ok(['Solid build', 'Fast delivery']), 1000));

const Reviews = ({ promise }) => <ul>{use(promise).map((t) => <li key={t}>{t}</li>)}</ul>;

const Page = () => (

<html lang="en">

<head><title>Keyboard</title></head>

<body>

<h1>Keyboard</h1>

<Suspense fallback={<p>Loading reviews...</p>}><Reviews promise={reviews()} /></Suspense>

</body>

</html>

);

createServer((req, res) => {

const { pipe } = renderToPipeableStream(<Page />, {

bootstrapModules: ['/client.js'],

onShellReady() { res.writeHead(200, { 'Content-Type': 'text/html' }); pipe(res); },

onShellError: () => res.writeHead(500).end('<h1>Server error</h1>'),

});

}).listen(4001, () => console.log('Streaming server on http://localhost:4001/'));

Listing 2.193: stream.jsx - a shell now, the slow part later Timestamping each chunk of the response shows the two deliveries: the reviews take a second, the heading does not wait for them.

[+ 37 ms] <!DOCTYPE html><html lang="en"><head><link rel="modulepreload" href="/client.js"/>

<title>Keyboard</title></head><body><h1>Keyboard</h1><!--$?-->

<template id="B:0"></template><p>Loading reviews...</p><!--/$-->

<script type="module" src="/client.js" id="_R_" async=""></script>

[+1042 ms] <div hidden id="S:0"><ul><li>Solid build</li><li>Fast delivery</li></ul></div>

<script>$RB=[];$RV=function(a){...};$RC("B:0","S:0")</script>

[+1042 ms] </body></html>

Output of Listing 2.193 The mechanism is visible there. The fallback ships inside a <!--$?--> marker with an empty <template id="B:0"> beside it; when the promise resolves, React appends the finished list in a hidden <div> and an inline script — 862 bytes, sent once however many boundaries follow — swaps it into the marker's place. No framework is involved, so the swap happens even before your bundle has downloaded.The callbacks decide the response. onShellReady fires when everything outside the boundaries has rendered: pipe there for a page that streams. onAllReady fires when every boundary has resolved: pipe there for crawlers, since streaming to a client that runs no scripts would leave it reading "Loading". onShellError is your only chance to send a 500, because once the first byte is out the status code is fixed. Errors inside a boundary are different: React keeps the fallback, reports them to onError, and lets the client retry that subtree while hydrating. Tip: renderToReadableStream in the same module is the same API for Web streams — Deno, Cloudflare Workers, Bun and other edge runtimes. Node has it too, but React's documentation recommends the pipeable version there. Either way you hydrate with hydrateRoot.

2.20.4 Static Prerendering

If a page's data does not change between requests, rendering it per request is waste: render it once at build time, write the HTML to a file, let a CDN serve it. React ships that API in a separate module, react-dom/static — prerenderToNodeStream in Node, prerender for Web-stream runtimes.

import { createWriteStream } from 'node:fs';

import { pipeline } from 'node:stream/promises';

import { prerenderToNodeStream } from 'react-dom/static';

import App from './app.jsx';

import { products } from './data.js';

const page = <html lang="en"><head><title>Catalog</title></head>

<body><App products={products} /></body></html>;

const { prelude } = await prerenderToNodeStream(page, { bootstrapModules: ['/client.js'] });

await pipeline(prelude, createWriteStream('public/index.html'));

Listing 2.194: build.jsx - one render, one file, no server The difference from renderToPipeableStream is the promise. Prerendering resolves only after every Suspense boundary has settled, and the prelude it returns is a complete document — 391 bytes for the catalog above, <!DOCTYPE html>, the injected modulepreload link and all. Nothing streams, because no reader is waiting; in exchange you get a file any static host serves with no Node process behind it. The page still hydrates: bootstrapModules puts the script tag in the file, and hydrateRoot runs as before.Loop over your data, call this once per page, and you have a generated site. The trade is freshness — a price change means a rebuild — so it fits documentation, marketing pages and catalogs on a schedule, and a per-user dashboard not at all. Under the hood: The module also exports resumeAndPrerenderToNodeStream, and react-dom/server exports resume. Together they split one render in two: prerender the static shell at build time, then resume the unfinished boundaries per request with the reader's own data. Frameworks build partial prerendering on this pair; Chapter 5 shows it from the outside.

2.20.5 React Server Components in Concept

Server rendering runs your components in both places. Server Components add a third: code that runs before bundling, at build time or per request, in an environment that is neither the client nor the SSR pass. It never reaches the browser, so such a component may await a database query, read a file, or import a 200 KB markdown parser without a byte of it being downloaded.Figure 2.64: Where a Server Component's work happens, and what the browser receivesDirectives mark the split. A file whose first line is 'use client' is a boundary: that module and everything it imports are compiled into the client bundle, and its components behave as they have all chapter — state, Effects, onClick. Everything above the boundary stays on the server, and there is no directive for it; a Server Component is simply one that no client module imports. It may be async and await in its body, and it may pass rendered JSX down as props or children, which is how a server tree wraps interactive islands. 'use server' is different: it marks Server Functions, which the client calls over the network, not components.Two consequences follow: Server Components hold no state and attach no handlers, and values crossing to the client must be serializable, so passing a function or a class instance is an error you will meet early.The machinery behind this — a bundler that understands the directives, a route that serializes the payload, a client runtime that requests it — is a framework's job. Chapter 5 builds real pages this way in Next.js: where the boundary belongs, and how Server Functions replace an API endpoint for forms.

2.20.6 Choosing a Rendering Strategy

Three questions settle it. Must the page be indexed or shared as a link? Does its content differ per reader? How fresh must it be? Mix the answers freely: the unit of decision is the route, not the application. Strategy Renders Best for Cost Client only In the browser Dashboards, editors, anything behind a login Blank first paint Static prerender At build time Docs, marketing, catalogs Rebuild to refresh Streaming SSR Per request Personalized pages that must be indexed A Node process under load Server Components Before bundling Data-heavy pages with small interactive parts A framework Table 2.28: Choosing where a React page renders Start with the cheapest option that meets the requirements, and measure before adding a tier. Server rendering buys a faster first paint and indexable HTML; it does not make a running application faster, and it will not rescue a page whose real problem is a 900 KB bundle or an unindexed query. If you do adopt it, rely on a framework: caching, routing, data loading, per-route error boundaries and asset manifests are all part of the job, and each is a source of bugs when hand-rolled. The APIs here are the ones frameworks call, and knowing them turns their documentation from magic into plumbing.Keep the catalog project open. The next chapters give its components something real to talk to — an Express API in Chapter 3, MongoDB behind it in Chapter 4 — and Chapter 5 hands the rendering pipeline you just built by hand to Next.js.