Scientyfic World

How to Implement Server-Side Rendering in React (Express, Vite and React 19)

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...

Share:

Get an AI summary of this article

blog feature image server-side rendering in react

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 serverAn empty <div id="root">The complete page content
Content appearsAfter JavaScript downloads, runs and fetches dataAs soon as the HTML arrives
Works without JavaScriptNoContent yes; interactivity no
Server neededStatic hosting is enoughA Node.js (or edge) server renders each request
SEOSearch engines must run JavaScriptContent 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:

Timeline comparing server-side rendering, where products are visible after about 190 ms, with client-side rendering, where they appear after about 930 ms
When the products appeared on screen in my test: server-side rendering vs client-side rendering of the same app.
First Contentful PaintProducts on screenWith JavaScript disabled
SSR224–296 ms183–244 msFull product list visible
CSR956–992 ms925–955 msBlank 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 sirv

Create 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 browser

Step 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 /about returned “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 window or reading localStorage during 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:

SerializationResult
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?

OptionWhat 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.jsSSR, static generation and React Server Components, with a large ecosystem and hosting built around it
AstroMostly 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.

Snehasish Konger
Developed @scientyficworld.org | Technical writer @Nected | Content Developer
Connect with Snehasish Konger

On This page

Take a Pause with Intervals

A Sunday letter on building, writing, and thinking deeper as a developer — short, honest, and worth your time.

Snehasish Konger profile photo

"Hey there — I'm Snehasish. Hope this post saved you some head-scratching time! I've spent years turning technical chaos into clarity, and I'm here to be your guide through the maze of modern tech. Stick around for more lightbulb moments — we're just getting started."

Related Posts