computers

Server-Side Rendering with React

1202 words7 min read

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'
import App from './App'

function Init() {
  return (
    <Router>
      <App />
    </Router>
  )
}

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'

export function Hello() {
  return <h3>Hello world!</h3>
}

export function HelloName() {
  const { name } = useParams()
  return <h3>Hello {name}</h3>
}

export default function App() {
  return (
    <>
      <header>appname</header>
      <main>
        <Route exact path="/" children={<Hello />} />
        <Route path="/:name" children={<HelloName />} />
      </main>
    </>
  )
}

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
const app = express()

app.use(express.static(path.resolve(__dirname, '../public')))
app.listen(PORT, () => {
  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.

// webpack.config.js
const path = require('path')
const MiniCssExtractPlugin = require('mini-css-extract-plugin')

const babel = {
  test: /\.jsx?$/,
  exclude: /node_modules/,
  use: 'babel-loader',
}

const css = {
  test: /\.css$/,
  use: [MiniCssExtractPlugin.loader, 'css-loader'],
}

module.exports = {
  entry: { main: './src/client/index' },
  resolve: { extensions: ['.js', '.jsx'] },
  output: {
    path: path.resolve(__dirname, 'public'),
    filename: '[name]-[contentHash:8].js',
  },
  module: {
    rules: [babel, css],
  },
  plugins: [
    new MiniCssExtractPlugin({
      filename: '[name]-[contentHash:8].css',
    }),
  ],
  optimization: {
    splitChunks: {
      cacheGroups: {
        vendor: {
          test: /[\\/]node_modules[\\/]()[\\/]/,
          name: 'vendor',
          chunks: 'all',
        },
      },
    },
  },
}
// babel.config.js
module.exports = {
  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 path from 'path'
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
const app = express()

app.use(express.static(path.resolve(__dirname, '../public')))
app.use((req, res) => {
  const routerCtx = {}
  const appHtml = ReactDOMServer.renderToString(
    <Router location={req.url} context={routerCtx}>
      <App />
    </Router>,
  )
  const html = ReactDOMServer.renderToStaticMarkup(
    <html>
      <body>
        <div id="root" dangerouslySetInnerHTML={{ __html: appHtml }} />
      </body>
    </html>,
  )
  res.send(`<!doctype html>${html}`)
})
app.listen(PORT, () => {
  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:

npm i -D @babel/node

then try running the server with 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')

 module.exports = {
   // ...
   plugins: [
+    new ManifestPlugin({
+      writeToFileEmit: true,
+      seed: () => ({
+        "assets": {
+          "scripts": [],
+          "styles": [],
+        },
+      }),
+      generate: (seed, files, entrypoints) => files.reduce((manifest, { path }) => {
+        if (path.endsWith('.js')) manifest.assets.scripts.push(path)
+        else if (path.endsWith('.css')) manifest.assets.styles.push(path)
+        return manifest
+      }, seed()),
+    }),
     // ...
   ],
 }

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 path from 'path'
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
const app = express()

app.use(express.static(path.resolve(__dirname, '../public')))

app.use((req, res) => {
  const routerCtx = {}
  const appHtml = ReactDOMServer.renderToString(
    <Router location={req.url} context={routerCtx}>
      <App />
    </Router>,
  )
  const html = ReactDOMServer.renderToStaticMarkup(
    <html>
      <head>
        {assets.styles.map(p => (
          <link href={`/${p}`} key={p} rel="stylesheet" type="text/css" />
        ))}
      </head>
      <body>
        <div id="root" dangerouslySetInnerHTML={{ __html: appHtml }} />
        {assets.scripts.map(p => (
          <script src={`/${p}`} key={p} defer type="text/javascript" />
        ))}
      </body>
    </html>,
  )
  res.send(`<!doctype html>${html}`)
})

app.listen(PORT, () => {
  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:

app.use((req, res) => {
  const routerCtx = {}
  const appHtml = ReactDOMServer.renderToString(
    <Router location={req.url} context={routerCtx}>
      <App />
    </Router>,
  )
  if (routerCtx.url) {
    res.redirect(routerCtx.statusCode || 307, routerCtx.url)
    return
  }
  if (routerCtx.statusCode) {
    res.sendStatus(routerCtx.statusCode)
  }
  const html = ReactDOMServer.renderToStaticMarkup(
    <html>
      <head>
        {assets.styles.map(p => (
          <link href={`/${p}`} key={p} rel="stylesheet" type="text/css" />
        ))}
      </head>
      <body>
        <div id="root" dangerouslySetInnerHTML={{ __html: appHtml }} />
        {assets.scripts.map(p => (
          <script src={`/${p}`} key={p} defer type="text/javascript" />
        ))}
      </body>
    </html>,
  )
  res.send(`<!doctype html>${html}`)
})

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.

npm i 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'

 import App from './App'

 function Init() {
   return (
+    <HelmetProvider>
       <Router>
         <App />
       </Router>
+    </HelmetProvider>
   )
 }
 ReactDOM.hydrate(<Init />, document.querySelector('#root'))

and then again in the server:

import { HelmetProvider } from 'react-helmet-async'
// ...
app.use((req, res) => {
  const routerCtx = {}
  const helmetCtx = {}
  const appHtml = ReactDOMServer.renderToString(
    <HelmetProvider context={helmetCtx}>
      <Router location={req.url} context={routerCtx}>
        <App />
      </Router>
    </HelmetProvider>,
  )
  if (routerCtx.url) {
    res.redirect(routerCtx.statusCode || 307, routerCtx.url)
    return
  }
  if (routerCtx.statusCode) {
    res.sendStatus(routerCtx.statusCode)
  }
  const { helmet } = helmetCtx
  const html = ReactDOMServer.renderToStaticMarkup(
    <html>
      <head>
        {helmet.title.toComponent()}
        {helmet.meta.toComponent()}
        {helmet.link.toComponent()}
        {assets.styles.map(p => (
          <link href={`/${p}`} key={p} rel="stylesheet" type="text/css" />
        ))}
      </head>
      <body>
        <div id="root" dangerouslySetInnerHTML={{ __html: appHtml }} />
        {assets.scripts.map(p => (
          <script src={`/${p}`} key={p} defer type="text/javascript" />
        ))}
      </body>
    </html>,
  )
  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'

export function Hello() {
  return <h3>Hello world!</h3>
}

export function HelloName() {
  const { name } = useParams()
  return (
    <>
      <Helmet>
        <title>Hello {name}!</title>
      </Helmet>
      <h3>Hello {name}!</h3>
    </>
  )
}

export default function App() {
  return (
    <>
      <Helmet defaultTitle="appname" titleTemplate="appname | %s">
        <meta name="example" content="whatever" />
      </Helmet>
      <header>appname</header>
      <main>
        <Route exact path="/" children={<Hello />} />
        <Route path="/:name" children={<HelloName />} />
      </main>
    </>
  )
}