logosmonn
Cover photo by Ties Rademacher

Minimal server-side rendering with React, part 2

Published by Simon Ingeson

This is part of a series:

  1. Initial setup with @babel/*, react, and express
  2. Configure nodemon and react-router (current post)
  3. Setup production build and benchmarking

This blog post is a follow-up to my previous one. This time we’ll add React Router for routing and nodemon to automatically restart on code changes. If you haven’t followed along, feel free to clone the current version and checkout tag part-1.

Install dependencies

In your terminal, run:

npm install react-router-dom
npm install --save-dev nodemon

Update package.json

  "scripts": {
+   "dev": "nodemon --require @babel/register index.js",
    "start": "node --require @babel/register index.js"
  },

Add Layout component

This component will wrap our page components and includes the navigation links.

// ./components/layout.js
import { NavLink } from 'react-router-dom'

export default function Layout({ children }) {
  return (
    <>
      <nav>
        <NavLink to="/" exact>
          Home
        </NavLink>
        {' | '}
        <NavLink to="/about">About</NavLink>
      </nav>
      <main>{children}</main>
    </>
  )
}

Add Home and About page components

// ./pages/index.js
import Layout from '../components/Layout'

export default function Index() {
  return (
    <Layout>
      <h1>Home</h1>
    </Layout>
  )
}
// ./pages/about.js
import Layout from '../components/Layout'

export default function About() {
  return (
    <Layout>
      <h1>About</h1>
    </Layout>
  )
}

Add App component

This component can also be considered the root or router component. Here we will configure all of the routes in our app.

// ./components/app.js
import { Route, Switch } from 'react-router-dom'
import Index from '../pages'
import About from '../pages/About'

export default function App() {
  return (
    <Switch>
      <Route path="/" exact>
        <Index />
      </Route>
      <Route path="/about">
        <About />
      </Route>
    </Switch>
  )
}

Extract the rendering logic into a function

By extracting this code into a separate file and function, we can more easily make changes to the rendering logic going forward. It also reduces some cognitive overload by not having the express routes and setup in view.

// ./lib/render-app.js
import { renderToString } from 'react-dom/server'
import { StaticRouter } from 'react-router-dom'
import App from '../components/app'

/**
 * Renders the App into a string.
 * @param {string} url The current URL to render
 * @param {object} staticContext Context for static router
 */
export default function renderApp(url, staticContext) {
  // To avoid putting all of this in a string, we'll use JSX for html, head, body, etc. too.
  let markup = renderToString(
    <html lang="en">
      <head>
        <meta charSet="UTF-8" />
        <meta name="viewport" content="width=device-width" />
        <meta name="description" content="Hello World" />
        <title>Hello World</title>
      </head>
      <body>
        <StaticRouter location={url} context={staticContext}>
          <App />
        </StaticRouter>
      </body>
    </html>
  )

  // Since we don't care about hydration here, we can safely remove the `data-reactroot` attribute.
  markup = markup.replace(` data-reactroot=""`, '')

  // We need to set the doctype here as we can't include it using JSX.
  return `<!DOCTYPE html>${markup}`
}

Replace the contents of index.js

After the previous step, we can remove quite a bit of code in index.js. But as a result of dealing with redirects and potential 404:s, we had to add some new code instead. However, this code feels more at home as it directly interacts with the req and res objects.

// ./index.js
import express from 'express'
import renderApp from './lib/render-app'

const app = express()

// This wildcard route will ensure we catch all GET requests.
app.get('/*', (req, res) => {
  // This context lets us track redirects and 404:s triggered by React Router.
  const context = {}
  const html = renderApp(req.url, context)

  if (context.url) {
    // This means a redirect was triggered.
    res.redirect(context.statusCode || 301, context.url)
  } else {
    if (context.statusCode) {
      // This usually means we have a 404.
      res.status(context.statusCode)
    }

    res.send(html)
  }
})

app.listen(process.env.PORT || 3000)

Add “404 Not Found” page component and route

To ensure we show an accurate “404 Not Found” page, we need to set the status code and display an error message. I recommend reading The Perfect 404 for ideas and suggestions.

// ./pages/404.js
import { Link, Route } from 'react-router-dom'

export default function NotFound() {
  return (
    <Route
      render={({ staticContext }) => {
        if (staticContext) {
          staticContext.statusCode = 404
        }

        return (
          <main>
            <h1>Page Not Found</h1>
            <p>
              <Link to="/">Return to home</Link>
            </p>
          </main>
        )
      }}
    />
  )
}

Update ./components/app.js:

  import { Route, Switch } from "react-router-dom";
  import Index from "../pages";
+ import NotFound from "../pages/404";
  import About from "../pages/About";

  export default function App() {
    return (
      <Switch>
        <Route path="/" exact>
          <Index />
        </Route>
        <Route path="/about">
          <About />
        </Route>
+       <Route path="*" component={NotFound} />
      </Switch>
    );
  }

While still not production-ready, these additions will allow us to configure multiple routes and pages and support HTTP 404. You can find the source code on GitHub. In the next blog post, we’ll add the essential build step to running our code without @babel/register.

Cover photo by Ties Rademacher.