To implement server-side rendering (SSR) in React, render your component tree to HTML on the server with renderToPipeableStream from react-dom/server, send that HTML to the browser along with the data it was rendered from, and then call hydrateRoot in the browser so React attaches event handlers to the existing markup instead of rebuilding it. Users and search engines see the content immediately, and the page becomes interactive once the JavaScript loads.
This guide builds that setup step by step with React 19.3, React Router 8, Express 5 and Vite 8 (the same pattern Vite’s official SSR examples use), then measures the difference against client-side rendering and covers the two mistakes that break most SSR apps: hydration mismatches and unsafe data serialization. If you’d rather not run your own server, the last section compares frameworks that do this for you.
Server-Side vs Client-Side Rendering
| Client-side rendering (CSR) | Server-side rendering (SSR) | |
|---|---|---|
| First HTML from the server | An empty <div id="root"> | The complete page content |
| Content appears | After JavaScript downloads, runs and fetches data | As soon as the HTML arrives |
| Works without JavaScript | No | Content yes; interactivity no |
| Server needed | Static hosting is enough | A Node.js (or edge) server renders each request |
| SEO | Search engines must run JavaScript | Content is in the HTML |
I built the same small shop app both ways and loaded it with simulated slow mobile conditions (150 ms latency, about 1.6 Mbps, CPU slowed 4x), five times each:

| First Contentful Paint | Products on screen | With JavaScript disabled | |
|---|---|---|---|
| SSR | 224–296 ms | 183–244 ms | Full product list visible |
| CSR | 956–992 ms | 925–955 ms | Blank page |
With SSR the products were part of the HTML, so they appeared before the first paint finished; with CSR the browser had to download and run the JavaScript, fetch the data and render before anything useful showed. The trade-off is server work for every request and a more complex setup. For a broader look at how the browser turns that HTML into pixels, see how the browser rendering engine works.
Step 1: Create the Project
npm create vite@latest ssr-shop -- --template react
cd ssr-shop
npm install react-router express compression sirvCreate React App is deprecated and never supported SSR, so use Vite. The project needs two entry points (one for the server, one for the browser) plus an Express server:
ssr-shop/
index.html HTML template with placeholders
server.js Express server: renders each request
src/
App.jsx your routes and components (shared)
data.js data loading (runs on the server)
entry-server.jsx renders App to an HTML stream
entry-client.jsx hydrates the HTML in the browserStep 2: The HTML Template
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SSR shop</title>
</head>
<body>
<div id="root"><!--app-html--></div>
<!--app-data-->
<script type="module" src="/src/entry-client.jsx"></script>
</body>
</html>The server replaces <!--app-html--> with the rendered app and <!--app-data--> with the data the page was rendered from.
Step 3: Shared App and Routes
import { useState } from 'react';
import { Routes, Route, Link } from 'react-router';
function Home({ products }) {
const [cart, setCart] = useState(0); // interactive only after hydration
return (
<main>
<h1>Products</h1>
<ul>{products.map((p) => <li key={p.id}>{p.name}: ${p.price}</li>)}</ul>
<button onClick={() => setCart(cart + 1)}>Add to cart ({cart})</button>
</main>
);
}
function About() {
return <main><h1>About us</h1></main>;
}
export default function App({ data }) {
return (
<>
<nav><Link to="/">Home</Link> | <Link to="/about">About</Link></nav>
<Routes>
<Route path="/" element={<Home products={data.products} />} />
<Route path="/about" element={<About />} />
</Routes>
</>
);
}// Pretend database call (in a real app: a query or an API request)
export async function getProducts() {
await new Promise((r) => setTimeout(r, 50));
return [
{ id: 1, name: 'Mechanical keyboard', price: 89 },
{ id: 2, name: 'Wireless mouse', price: 29 },
{ id: 3, name: 'USB-C dock', price: 119 },
];
}The same components run on the server and in the browser. Routing works with React Router’s StaticRouter on the server and BrowserRouter in the browser; see the React Router tutorial for routing basics.
Step 4: The Server Entry
import { StrictMode } from 'react';
import { renderToPipeableStream } from 'react-dom/server';
import { StaticRouter } from 'react-router';
import App from './App';
export function render(url, data, options) {
return renderToPipeableStream(
<StrictMode>
<StaticRouter location={url}>
<App data={data} />
</StaticRouter>
</StrictMode>,
options,
);
}renderToPipeableStream streams HTML as it renders, so the server can start sending the page before every component has finished (and it supports Suspense), unlike the older renderToString, which waits for the whole tree.
Step 5: The Client Entry (Hydration)
import { StrictMode } from 'react';
import { hydrateRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router';
import App from './App';
// Attach React to the HTML the server already sent, using the same data
hydrateRoot(
document.getElementById('root'),
<StrictMode>
<BrowserRouter>
<App data={window.__INITIAL_DATA__} />
</BrowserRouter>
</StrictMode>,
);hydrateRoot, not createRoot, tells React the HTML already exists. React renders the same tree in memory, checks it matches, and attaches event handlers. That’s why the client must receive exactly the same data the server used.
Step 6: The Express Server
import fs from 'node:fs/promises';
import express from 'express';
import { Transform } from 'node:stream';
import { getProducts } from './src/data.js';
const isProduction = process.env.NODE_ENV === 'production';
const port = process.env.PORT || 5173;
const app = express();
let vite;
if (!isProduction) {
// Development: Vite compiles JSX on the fly and provides hot reload
const { createServer } = await import('vite');
vite = await createServer({ server: { middlewareMode: true }, appType: 'custom' });
app.use(vite.middlewares);
} else {
// Production: serve the built client files
const compression = (await import('compression')).default;
const sirv = (await import('sirv')).default;
app.use(compression());
app.use('/', sirv('./dist/client', { extensions: [] }));
}
// Escape "<" so data can never close the <script> tag (prevents XSS)
const serialize = (data) => JSON.stringify(data).replace(/</g, '\\u003c');
app.use('*all', async (req, res) => {
try {
const url = req.originalUrl;
let template, render;
if (!isProduction) {
template = await vite.transformIndexHtml(url, await fs.readFile('./index.html', 'utf-8'));
render = (await vite.ssrLoadModule('/src/entry-server.jsx')).render;
} else {
template = await fs.readFile('./dist/client/index.html', 'utf-8');
render = (await import('./dist/server/entry-server.js')).render;
}
const data = { products: await getProducts() }; // load data on the server
const [htmlStart, htmlEnd] = template
.replace('<!--app-data-->', `<script>window.__INITIAL_DATA__ = ${serialize(data)}</script>`)
.split('<!--app-html-->');
const { pipe, abort } = render(url, data, {
onShellReady() {
res.status(200).set({ 'Content-Type': 'text/html' });
res.write(htmlStart);
const body = new Transform({ transform(chunk, enc, cb) { res.write(chunk, enc); cb(); } });
body.on('finish', () => res.end(htmlEnd));
pipe(body); // stream React's HTML as it renders
},
onShellError() {
res.status(500).set({ 'Content-Type': 'text/html' }).send('<h1>Something went wrong</h1>');
},
onError(error) {
console.error(error);
},
});
setTimeout(() => abort(), 10000); // give up on slow renders
} catch (e) {
vite?.ssrFixStacktrace(e);
console.error(e);
res.status(500).end(e.stack);
}
});
app.listen(port, () => console.log(`Server started at http://localhost:${port}`));// package.json
"scripts": {
"dev": "node server.js",
"build": "vite build --outDir dist/client && vite build --ssr src/entry-server.jsx --outDir dist/server",
"start": "NODE_ENV=production node server.js"
}In development, Vite compiles JSX on the fly; in production you build both bundles once and serve the client files with sirv. If you’re new to Express, see how to build REST APIs with Node and Express.
What I verified
curl http://localhost:5300/returned the full product list and navigation in the HTML, and/aboutreturned “About us”: no JavaScript involved.- In the browser, the “Add to cart” button worked after hydration (two clicks showed “Add to cart (2)”), and clicking “About” navigated without a page reload, with no console errors.
- With JavaScript disabled, the SSR page still showed every product; the CSR version showed a blank page.
Hydration Mismatches
If the server and the browser render different output, React can’t reuse the HTML. I added a route that renders Math.random(), and the browser threw: “Hydration failed because the server rendered text didn’t match the client. As a result this tree will be regenerated on the client.” React then re-rendered the whole tree on the client, losing SSR’s benefit. Common causes:
- Random values,
Date.now()or the current time rendered directly. - Checking
typeof windowor readinglocalStorageduring render. - Locale or time-zone formatting that differs between server and browser.
- Invalid HTML nesting, such as a
<div>inside a<p>, which the browser’s parser rearranges. - Different data on server and client (always pass the server’s data down, as above).
Fix: render browser-only values in useEffect (which doesn’t run on the server), pass dates and random seeds from the server, and use suppressHydrationWarning only for unavoidable one-element differences like a timestamp.
Safely Passing Data to the Client
Putting server data into a <script> tag with plain JSON.stringify is a cross-site scripting hole. I gave one product the name </script><script>window.__pwned = true</script> and tested both versions:
| Serialization | Result |
|---|---|
JSON.stringify(data) | The name closed the script tag early, the injected script ran (window.__pwned === true), and the product disappeared from the page |
JSON.stringify(data).replace(/</g, '\u003c') | No script ran; the product name was displayed as harmless text |
Escaping < as \u003c keeps the JSON valid while making it impossible to close the tag. Libraries such as serialize-javascript do the same with more cases covered.
SEO and Performance Notes
- Render titles, meta descriptions and canonical tags on the server too. React 19 lets components render
<title>and<meta>anywhere, and they’re hoisted to the head. More on this in how to optimize a React app for SEO. - Cache rendered pages (or the data) where possible; SSR costs CPU on every request.
- SSR shows content sooner, but the page isn’t interactive until hydration finishes. Keep the client bundle small, and split code by route.
Should You Use a Framework Instead?
| Option | What you get |
|---|---|
| This guide (Express + Vite) | Full control and few dependencies; you handle data loading, caching, errors and builds |
| React Router framework mode (see what React Router is) | Route loaders and actions run on the server, streaming SSR, pre-rendering and type-safe routes |
| Next.js | SSR, static generation and React Server Components, with a large ecosystem and hosting built around it |
| Astro | Mostly static HTML with React “islands” only where you need interactivity |
For a new production app, a framework saves a lot of plumbing. Building SSR by hand, as above, is the best way to understand what those frameworks do, and still a sensible choice for adding SSR to an existing Express app.
Conclusion
Server-side rendering in React comes down to three pieces: render the app to HTML on the server with renderToPipeableStream, send the HTML together with safely serialized data, and hydrate it with hydrateRoot using the same data. In my test that put products on screen in about 190 ms instead of about 930 ms on a throttled connection, and kept the content visible with JavaScript turned off. Avoid render output that differs between server and browser, always escape the data you embed, and consider React Router or Next.js when you’d rather not maintain the server yourself.
FAQs
Does React support server-side rendering?
Yes. react-dom/server provides renderToPipeableStream (Node.js streams), renderToReadableStream (web streams) and renderToString. The browser then hydrates the HTML with hydrateRoot from react-dom/client.
How does server-side rendering work in React?
The server runs your components and returns the resulting HTML, so the page content arrives with the first response. The browser shows it immediately, loads the JavaScript, and React hydrates the existing HTML by attaching event handlers.
What is hydration in React?
Hydration is React taking over server-rendered HTML in the browser: it renders the same component tree, checks it matches the HTML, and attaches event handlers instead of recreating the DOM.
What causes a hydration mismatch?
Rendering different output on the server and the client, for example random numbers, the current time, browser-only APIs like localStorage during render, or invalid HTML nesting. Move browser-only code into useEffect.
Is SSR better for SEO?
Usually. The content and meta tags are in the HTML response, so search engines and social previews can read them without running JavaScript. Google can render JavaScript, but SSR is faster and more reliable.
Should I use Next.js or build SSR myself?
For most new production apps, a framework such as Next.js or React Router framework mode handles data loading, caching, streaming and builds for you. Hand-built SSR with Express and Vite gives full control and is useful for existing servers or learning.








