Server rendering in React is often seen as mystical and esoteric, let's shed some light on it!
Getting Started
Let's take the following typical react client setup:
import React from 'react'
import ReactDOM from 'react-dom'
import { BrowserRouter as Router } from 'react-router-dom'
ReactDOM.render(<Init />, document.querySelector('#root'))
Note that the setup logic for rendering and routing is separated from the rest of app code.
A trivial example app might look like:
import React, { useState } from 'react'
import { Route, useParams } from 'react-router-dom'
return <h3>Hello world!</h3>
const { name } = useParams()
return <h3>Hello {name}</h3>
return <h1>404 not found</h1>
export default function App() {
<Route exact path="/" component={Hello} />
<Route path="/:name" component={HelloName} />
<Route component={NotFound} />
Server Rendering
First, let's setup a simple express server that serves static files:
const path = require('path')
const express = require('express')
const port = process.env.PORT
app.use(express.static(path.resolve(__dirname, '../public')))
console.log(`Server is running on http://localhost:${port}`),
Combined with the configuration from my babel-webpack guide (copied below for completeness), you should have a way to generate static files from your client code, and now a way to serve them over http.
const path = require('path')
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
use: [MiniCssExtractPlugin.loader, 'css-loader'],
entry: { main: './src/client/index' },
resolve: { extensions: ['.js', '.jsx'] },
path: path.resolve(\_\_dirname, 'public'),
filename: '[name]-[contentHash:8].js',
new MiniCssExtractPlugin({
filename: '[name]-[contentHash:8].css',
presets: ['@babel/preset-env', '@babel/preset-react'],
Don't forget to install the build dependencies:
npm i -D webpack{,-cli} @babel/{core,preset-env,preset-react} {babel,css}-loader mini-css-extract-plugin
Now, let's add the React rendering logic:
import express from 'express'
import React from 'react'
import ReactDOMServer from 'react-dom/server'
import { StaticRouter as Router } from 'react-router-dom'
import App from './client/App.jsx'
const port = process.env.PORT
app.use(express.static(path.resolve(__dirname, '../public')))
const appHtml = ReactDOMServer.renderToString(
<Router location={req.url} context={routerCtx}>
const html = ReactDOMServer.renderToStaticMarkup(
<div id="root" dangerouslySetInnerHTML={{ __html: appHtml }} />
res.send(`<!doctype html>${html}`)
console.log(`Server is running on http://localhost:${port}`)
JSX in our server code won't work out of the box, nor will importing our application code that uses it. Fortunately this is an easy fix using babel during runtime:
then try running the server:
PORT=8000 npx babel-node -x .js,.jsx ./src/index.js
Again, but with assets
Currently our response isn't including any script or style tags. The best way I know how to solve this, is to ask webpack for the list of assets it created, which the webpackManifestPlugin
can help us with:
npm i -D webpack-manifest-plugin
const ManifestPlugin = require('webpack-manifest-plugin')
seed: () => ({ assets: { scripts: [], styles: [] } }),
generate(seed, files, entrypoints) {
return files.reduce((manifest, { path }) => {
if (path.endsWith('.js')) {
manifest.assets.scripts.push(path)
} else if (path.endsWith('.css')) {
manifest.assets.styles.push(path)
Now we have a file containing the list of assets webpack generated, we can use this to specify our css and js dependencies in our html response:
import express from 'express'
import React from 'react'
import ReactDOMServer from 'react-dom/server'
import { StaticRouter as Router } from 'react-router-dom'
import App from './client/App.jsx'
import { assets } from '../public/manifest.json'
const port = process.env.PORT
app.use(express.static(path.resolve(__dirname, '../public')))
const appHtml = ReactDOMServer.renderToString(
<Router location={req.url} context={routerCtx}>
const html = ReactDOMServer.renderToStaticMarkup(
{assets.styles.map(p => (
<link href={`/${p}`} key={p} rel="stylesheet" type="text/css" />
<div id="root" dangerouslySetInnerHTML={{ __html: appHtml }} />
{assets.scripts.map(p => (
<script src={`/${p}`} key={p} defer type="text/javascript" />
res.send(`<!doctype html>${html}`)
console.log(`Server is running on http://localhost:${port}`)
in order for react to attach event handlers to the existing dom nodes, we have a small change to make in our client setup:
-ReactDOM.render(<Init/>, document.querySelector('#root'))
+ReactDOM.hydrate(<Init/>, document.querySelector('#root'))
The rest of the fiddlings
We can add some integration with react-router and send status codes and redirects at the server level:
const appHtml = ReactDOMServer.renderToString(
<Router location={req.url} context={routerCtx}>
if (routerCtx.statusCode) res.sendStatus(routerCtx.statusCode)
if (routerCtx.url) return res.redirect(routerCtx.url)
const html = ReactDOMServer.renderToStaticMarkup(
{assets.styles.map(p => (
<link href={`/${p}`} key={p} rel="stylesheet" type="text/css" />
<div id="root" dangerouslySetInnerHTML={{ __html: appHtml }} />
{assets.scripts.map(p => (
<script src={`/${p}`} key={p} defer type="text/javascript" />
res.send(`<!doctype html>${html}`)
And now in our app we can add a statusCode
if necessary:
import React, { useState } from 'react'
import { Route, useParams } from 'react-router-dom'
return <h3>Hello world!</h3>
const { name } = useParams()
return <h3>Hello {name}</h3>
function NotFound({ staticContext }) {
if (staticContext) staticContext.statusCode = 404
return <h1>404 not found</h1>
export default function App() {
<Route exact path="/" component={Hello} />
<Route path="/:name" component={HelloName} />
<Route component={NotFound} />
One of the main things we're still missing is meta data in our <head/>
tags. A few packages exist for this, the best I've found is react-helmet-async.
First in our client setup we have to add the HelmetProvider
:
import React from 'react'
import ReactDOM from 'react-dom'
import { BrowserRouter as Router } from 'react-router-dom'
import { HelmetProvider } from 'react-helmet-async'
ReactDOM.hydrate(<Init />, document.querySelector('#root'))
and then again in the server:
import { HelmetProvider } from 'react-helmet-async'
const appHtml = ReactDOMServer.renderToString(
<HelmetProvider context={helmetCtx}>
<Router location={req.url} context={routerCtx}>
if (routerCtx.statusCode) res.sendStatus(routerCtx.statusCode)
if (routerCtx.url) return res.redirect(routerCtx.url)
const { helmet } = helmetCtx
const html = ReactDOMServer.renderToStaticMarkup(
{helmet.title.toComponent()}
{helmet.meta.toComponent()}
{helmet.link.toComponent()}
{assets.styles.map(p => (
<link href={`/${p}`} key={p} rel="stylesheet" type="text/css" />
<div id="root" dangerouslySetInnerHTML={{ __html: appHtml }} />
{assets.scripts.map(p => (
<script src={`/${p}`} key={p} defer type="text/javascript" />
res.send(`<!doctype html>${html}`)
Now we can update our app to have some common metadata on each page, and use different titles for each route:
import React, { useState } from 'react'
import { Route, useParams } from 'react-router-dom'
import { Helmet } from 'react-helmet-async'
return <h3>Hello world!</h3>
const { name } = useParams()
<title>Hello {name}!</title>
function NotFound({ staticContext }) {
if (staticContext) staticContext.statusCode = 404
return <h1>404 not found</h1>
export default function App() {
<Helmet defaultTitle="appname" titleTemplate="appname | %s">
<meta name="example" content="whatever" />
<Route exact path="/" children={<Hello />} />
<Route path="/:name" children={<HelloName />} />