Comments
- Loading...
Server rendering in React is often seen as mystical and esoteric, let's shed some light on it!
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 from 'react'import { Route, useParams } from 'react-router-dom'function Hello() {return <h3>Hello world!</h3>}function HelloName() {const { name } = useParams()return <h3>Hello {name}</h3>}function NotFound() {return <h1>404 not found</h1>}export default function App() {return (<><header>appname</header><main><Route exact path="/" component={Hello} /><Route path="/:name" component={HelloName} /><Route component={NotFound} /></main></>)}
First, let's set up a simple express server that serves static files:
const path = require('path')const express = require('express')const port = process.env.PORTconst 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.
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',}),],}
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.PORTconst 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:
PORT=8000 npx babel-node -x .js,.jsx ./src/index.js
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) {return 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.PORTconst 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'))
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.statusCode) res.status(routerCtx.statusCode)if (routerCtx.url) return res.redirect(routerCtx.url)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}`)})
And now in our app we can add a statusCode if necessary:
import React from 'react'import { Route, useParams } from 'react-router-dom'function Hello() {return <h3>Hello world!</h3>}function HelloName() {const { name } = useParams()return <h3>Hello {name}</h3>}function NotFound({ staticContext }) {if (staticContext) staticContext.statusCode = 404return <h1>404 not found</h1>}export default function App() {return (<><header>appname</header><main><Route exact path="/" component={Hello} /><Route path="/:name" component={HelloName} /><Route component={NotFound} /></main></>)}
One of the main things we're still missing is metadata 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.statusCode) res.status(routerCtx.statusCode)if (routerCtx.url) return res.redirect(routerCtx.url)const { helmet } = helmetCtxconst 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 from 'react'import { Route, useParams } from 'react-router-dom'import { Helmet } from 'react-helmet-async'function Hello() {return <h3>Hello world!</h3>}function HelloName() {const { name } = useParams()return (<><Helmet><title>Hello {name}!</title></Helmet><h3>Hello {name}!</h3></>)}function NotFound({ staticContext }) {if (staticContext) staticContext.statusCode = 404return <h1>404 not found</h1>}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></>)}