Skip to main content
Server-side Rendering (SSR) helps render components into HTML strings on the server, send them to the browser, and “hydrate” the static markup into a fully interactive app on the client.

What is Server-Side Rendering?

SSR is a technique that:
1

Renders on Server

Components are rendered to HTML strings on the server
2

Sends to Browser

HTML is sent directly to the browser for fast initial load
3

Hydrates on Client

The static markup becomes a fully interactive React app

Basic SSR with React

Let’s build a simple stateless server-rendered app.

Required Dependencies

  • express - Build a web app that runs on Node
  • react - Build UI components
  • react-dom/server - Render components on the server
1

Configure TypeScript

tsconfig.json
{
  "compilerOptions": {
    "noImplicitAny": false,
    "noEmitOnError": true,
    "removeComments": false,
    "sourceMap": true,
    "target": "esnext"
  },
  "include": ["**/*"]
}
Remove all comments from your tsconfig.json file.
2

Create App Component

app.tsx
export const App = () => {
  return (
    <html>
      <head>
        <meta charSet="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <title>Static Server-side-rendered App</title>
      </head>
      <body>
        <div>Hello World!</div>
      </body>
    </html>
  )
}
3

Create Server

server.tsx
import express from 'express'
import React from 'react'
import ReactDOMServer from 'react-dom/server'

import { App } from './app.tsx'

const port = Number.parseInt(process.env.PORT || '3000', 10)
const app = express()

app.get('/', (_, res) => {
  const { pipe } = ReactDOMServer.renderToPipeableStream(<App />, {
    onShellReady() {
      res.setHeader('content-type', 'text/html')
      pipe(res)
    },
  })
})

app.listen(port, () => {
  console.log(`Server is listening at ${port}`)
})
4

Build and Run

tsc --build
node server.js

Hydration

Hydration turns the initial HTML snapshot from the server into a fully interactive app. The React tree you pass to hydrateRoot must produce the same output as it did on the server.

Adding Client-Side Interactivity

For stateful apps, you need to hydrate the server-rendered HTML:
  • react-dom/client - Hydrate components on the client
1

Create App Component

app.tsx
export const App = () => {
  return (
    <html>
      <head>
        <meta charSet="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <title>Server-side-rendered App with Hydration</title>
      </head>
      <body>
        <div>Hello World!</div>
      </body>
    </html>
  )
}
2

Create Client Entry Point

main.tsx
import ReactDOMClient from 'react-dom/client'

import { App } from './app.tsx'

ReactDOMClient.hydrateRoot(document, <App />)
3

Update Server

server.tsx
import express from 'express'
import React from 'react'
import ReactDOMServer from 'react-dom/server'

import { App } from './app.tsx'

const port = Number.parseInt(process.env.PORT || '3000', 10)
const app = express()

app.use('/', (_, res) => {
  const { pipe } = ReactDOMServer.renderToPipeableStream(<App />, {
    bootstrapScripts: ['/main.js'],
    onShellReady() {
      res.setHeader('content-type', 'text/html')
      pipe(res)
    },
  })
})

app.listen(port, () => {
  console.log(`Server is listening at ${port}`)
})
The bootstrapScripts option tells React to load main.js after rendering, which will hydrate the app.
4

Build and Run

tsc --build
node server.js

Common Hydration Errors

The React tree you pass to hydrateRoot needs to produce the same output as it did on the server.
The most common causes of hydration errors:
Extra whitespace (like newlines) around the React-generated HTML inside the root node.
// Bad - extra whitespace
<div>
  
  <App />
  
</div>

// Good
<div><App /></div>
Using checks like typeof window !== 'undefined' in your rendering logic.
// Bad - different output on server and client
function Component() {
  const isBrowser = typeof window !== 'undefined'
  return <div>{isBrowser ? 'Client' : 'Server'}</div>
}

// Good - use useEffect for client-only code
function Component() {
  const [mounted, setMounted] = useState(false)
  useEffect(() => setMounted(true), [])
  return <div>{mounted ? 'Client' : 'Server'}</div>
}
Using browser-only APIs like window.matchMedia in your rendering logic.
// Bad - window is not available on server
function Component() {
  const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches
  return <div>{isDark ? 'Dark' : 'Light'}</div>
}

// Good - use useEffect
function Component() {
  const [isDark, setIsDark] = useState(false)
  useEffect(() => {
    setIsDark(window.matchMedia('(prefers-color-scheme: dark)').matches)
  }, [])
  return <div>{isDark ? 'Dark' : 'Light'}</div>
}
Rendering different data on the server and client.
// Bad - Math.random() gives different values
function Component() {
  return <div>{Math.random()}</div>
}

// Good - use the same data source
function Component({ randomValue }) {
  return <div>{randomValue}</div>
}

Hydration with Zustand

When using Zustand with SSR:
Create a store per request to avoid sharing state between users:
import { createStore } from 'zustand/vanilla'

export const createCounterStore = (initialCount = 0) => {
  return createStore((set) => ({
    count: initialCount,
    inc: () => set((state) => ({ count: state.count + 1 })),
  }))
}
For detailed Next.js integration patterns, see the Next.js Guide.

Key Takeaways

Consistent Output

Server and client must render the same HTML

Per-Request Stores

Create new stores for each request, don’t share globally

Hydrate with Same Data

Initialize client store with server-rendered state

Avoid Browser APIs

Don’t use window/document during initial render

Resources

Build docs developers (and LLMs) love