Skip to content

React SSR Basics — Rendering React Components on the Server

DodaTech Updated 2026-06-28 6 min read

In this tutorial, you will learn about React SSR Basics. We cover key concepts, practical examples, and best practices to help you master this topic.

React SSR renders React components to HTML on the server using ReactDOMServer, sending fully-formed pages to browsers for faster initial load, better SEO, and improved Core Web Vitals.

What You'll Learn

By the end of this tutorial, you will understand how to set up React SSR from scratch, use ReactDOMServer to render components to HTML strings, handle data fetching on the server, create the client-side hydration bundle, and avoid common SSR pitfalls.

Why It Matters

React was designed for client-side rendering, but many applications need SSR for SEO and performance. Understanding how React SSR works at a low level — without frameworks like Next.js — gives you deep insight into how rendering works and helps you debug issues when using higher-level frameworks.

Real-World Use

A React-based documentation site switched from CSR to SSR using custom React SSR setup. First Contentful Paint dropped from 4.2s to 0.8s. Organic search traffic increased 150 percent after Google indexed all documentation pages within days instead of weeks.

React SSR Architecture
    ┌──────────────────────────────────────────────────────────┐
    │                 React SSR Setup                          │
    ├──────────────────────────────────────────────────────────┤
    │                                                          │
    │  Server Bundle                    Client Bundle          │
    │  ┌──────────────────────┐        ┌──────────────────┐   │
    │  │  server.js           │        │  client.js       │   │
    │  │  Express server      │        │  React hydrate    │   │
    │  │  ReactDOMServer      │        │  Attach events   │   │
    │  │  .renderToString()   │        │  Use __DATA__    │   │
    │  └──────────┬───────────┘        └────────┬─────────┘   │
    │             │                              │            │
    │             │  Same React Components       │            │
    │             └──────────────┬──────────────┘             │
    │                            │                            │
    │                    ┌───────▼────────┐                   │
    │                    │  App.jsx       │                   │
    │                    │  Shared code   │                   │
    │                    └────────────────┘                   │
    │                                                          │
    │  1. Server renders components to HTML                    │
    │  2. HTML sent to browser (immediately visible)           │
    │  3. Client JS loads, hydrates the HTML                   │
    │  4. App becomes interactive                              │
    └──────────────────────────────────────────────────────────┘

Think of React SSR like baking versus microwaving. CSR is microwaving — the meal (page) is not ready until the microwave beeps (JavaScript executes). SSR is baking a cake — the cake (HTML) comes out of the oven fully formed but needs frosting (hydration) before you can decorate it.

Setting Up React SSR

// 1. Server entry point (server.js)
const express = require('express');
const React = require('react');
const { renderToString } = require('react-dom/server');
const App = require('./src/App').default;

const app = express();
app.use(express.static('public'));

app.get('*', async (req, res) => {
    // Fetch data based on the URL
    const pageData = await fetchPageData(req.path);

    // Create the React element
    const appElement = React.createElement(App, {
        url: req.path,
        data: pageData
    });

    // Render to HTML string
    const appHtml = renderToString(appElement);

    // Send complete HTML page
    res.send(`
        <!DOCTYPE html>
        <html>
        <head>
            <title>React SSR App</title>
            <link rel="stylesheet" href="/styles.css">
        </head>
        <body>
            <div id="root">${appHtml}</div>
            <script>
                window.__INITIAL_DATA__ = ${JSON.stringify(pageData)};
            </script>
            <script src="/client.js"></script>
        </body>
        </html>
    `);
});

app.listen(3000, () => {
    console.log('SSR server running on http://localhost:3000');
});

Client-Side Hydration

// 2. Client entry point (client.js)
import React from 'react';
import { hydrateRoot } from 'react-dom/client';
import App from './src/App';

// Get initial data serialized by the server
const initialData = window.__INITIAL_DATA__;

// hydrateRoot instead of createRoot
// It attaches event handlers to the existing server-rendered HTML
const root = hydrateRoot(
    document.getElementById('root'),
    React.createElement(App, {
        url: window.location.pathname,
        data: initialData
    })
);

console.log('Hydration complete — app is interactive');

// Expected behavior:
// 1. Browser receives complete HTML (visible immediately)
// 2. Browser loads client.js
// 3. hydrateRoot attaches event handlers
// 4. Page becomes interactive without re-rendering
// 5. Subsequent navigations use client-side routing

Shared App Component

// 3. Shared App component (src/App.jsx)
import React, { useState } from 'react';

function App({ url, data }) {
    const [count, setCount] = useState(0);

    // Data is available immediately (from server or client)
    const [items] = useState(data.items || []);

    return (
        <div>
            <header>
                <nav>
                    <a href="/">Home</a>
                    <a href="/about">About</a>
                    <a href="/contact">Contact</a>
                </nav>
            </header>

            <main>
                <h1>Welcome to React SSR</h1>
                <p>This page was rendered on the server.</p>
                <p>Initial data has {items.length} items.</p>

                <button onClick={() => setCount(c => c + 1)}>
                    Clicked {count} times (interactive after hydration)
                </button>

                <ul>
                    {items.map((item, i) => (
                        <li key={i}>{item.name}</li>
                    ))}
                </ul>
            </main>
        </div>
    );
}

export default App;

Common Mistakes

  1. Using window, document, or localStorage on the server. These do not exist in Node.js. Wrap browser-only code in if (typeof window !== 'undefined') checks or use useEffect to run it only on the client.
  2. Hydration mismatch from different data. The HTML rendered on the server must match what hydrateRoot renders on the client. If the data differs, React throws hydration errors. Always pass the same data to both.
  3. Not handling redirects on the server. Client-side redirects (useNavigate, window.location) do not work on the server. Handle redirects with res.redirect() on the server and useNavigate on the client.
  4. CSS-in-JS without server-side extraction. CSS-in-JS libraries (styled-components, Emotion) need server-side extraction to avoid a flash of unstyled content. Configure them for SSR.
  5. Large bundle sizes. SSR sends the full JavaScript bundle. Without Code Splitting, users still download the entire application. Implement dynamic imports for route-based splitting.

Practice Questions

  1. What is the difference between renderToString and hydrateRoot?
  2. Why must the server-rendered HTML match the client-rendered HTML?
  3. How do you pass data from the server to the client in React SSR?
  4. What code should you avoid running on the server?
  5. How does hydration make a server-rendered page interactive?

Challenge: Build a React SSR application without frameworks: Express server that uses renderToString to render a React component, client-side hydration with hydrateRoot, data fetching on the server passed to the client via window.INITIAL_DATA, routing handled on both server and client, and a button that becomes interactive after hydration.

FAQ

Can I use React Hooks with SSR?

Yes, React Hooks work with SSR. However, hooks that rely on browser APIs (useEffect, useLayoutEffect) do not run on the server. They run during hydration on the client.

What is the difference between renderToString and renderToPipeableStream?

renderToString generates a single HTML string synchronously. renderToPipeableStream streams HTML to the browser, allowing Suspense and progressive rendering.

How do I handle routing in React SSR?

Use a router that works on both server and client, like React Router. Define routes in a shared configuration. On the server, match the URL and render the appropriate component.

Does React SSR support Suspense?

React 18 added Streaming SSR with Suspense support. Components wrapped in Suspense can stream their content as it becomes available on the server.

Should I use renderToString or create a Next.js app?

Use renderToString for learning or custom SSR setups. Use Next.js for production applications — it handles routing, code splitting, caching, and deployment automatically.

Mini Project

Build a React SSR application from scratch: Express server with renderToString, a shared App component with routing (Home, About, Contact pages), data fetching on the server (from a JSON file), client-side hydration with hydrateRoot, a counter button that works after hydration, and navigation links that use React Router on the client.

What's Next

You understand React SSR basics. Now dive deeper into renderToString and how it converts React components to HTML strings.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro