Server Side Rendering with React

By

Learn what server side rendering is, why it helps page speed and SEO, and how to render a React app to a string with Express, ReactDOMServer, and hydrateRoot.

~~~

Server Side Rendering, also called SSR, is the ability of a JavaScript application to render on the server rather than in the browser.

Why would we ever want to do so?

Without Server Side Rendering, all your server ships is an HTML page with no body, just some script tags that are then used by the browser to render the application.

Client-rendered apps are great at any subsequent user interaction after the first page load. Server Side Rendering allows us to get the sweet spot in the middle of client-rendered apps and backend-rendered apps: the page is generated server-side, but all interactions with the page once it’s been loaded are handled client-side.

However Server Side Rendering has its drawback too:

A very simplistic example of what it takes to Server-Side render a React app

SSR setups can grow very, very complex and most tutorials will bake in Redux, React Router and many other concepts from the start.

To understand how SSR works, let’s start from the basics to implement a proof of concept.

Feel free to skip this paragraph if you just want to look into the libraries that provide SSR and not bother with the ground work

To implement basic SSR we’re going to use Express.

If you are new to Express, or need some catch-up, check out my Express Tutorial here: https://flaviocopes.com/express/.

Warning: the complexity of SSR can grow with the complexity of your application. This is the bare minimum setup to render a basic React app. For more complex needs you might need to do a bit more work or also check out SSR libraries for React.

I assume you already have a React app created with Vite (see React installation). This post originally used create-react-app, which is deprecated. For production SSR, a framework like Next.js (how to install Next.js) does all of this for you. The Express example below is here to show what the React APIs do, not as a setup to ship as-is.

Go to the main app folder with the terminal, then run:

npm install express

You have a set of folders in your app directory. Create a new folder called server, then go into it and create a file named server.cjs.

Why .cjs? A Vite project has "type": "module" in package.json, so every .js file is an ES module. The Babel setup we’ll use below is CommonJS, and the .cjs extension tells Node.js to treat these two server files as CommonJS regardless of that setting.

Your app component lives in src/App.jsx. We’re going to load that component, and render it to a string using ReactDOMServer.renderToString(), which is provided by react-dom/server. React also offers renderToPipeableStream(), which streams the HTML and supports Suspense, but renderToString() is enough to see how SSR works.

You get the contents of the built index.html file (Vite puts it under dist/; older CRA builds used build/), and replace the <div id="root"></div> placeholder, which is the tag where the application hooks by default, with <div id="root">\${ReactDOMServer.renderToString(<App />)}</div>.

All the content inside the build output folder is going to be served as-is, statically by Express.

import path from 'path'
import fs from 'fs'

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

import App from '../src/App.jsx'

const PORT = 8080
const app = express()

const router = express.Router()

const serverRenderer = (req, res, next) => {
  fs.readFile(path.resolve('./dist/index.html'), 'utf8', (err, data) => {
    if (err) {
      console.error(err)
      return res.status(500).send('An error occurred')
    }
    return res.send(
      data.replace(
        '<div id="root"></div>',
        `<div id="root">${ReactDOMServer.renderToString(<App />)}</div>`
      )
    )
  })
}
router.get('/', serverRenderer)

router.use(
  express.static(path.resolve(__dirname, '..', 'dist'), { maxAge: '30d' })
)

// tell the app to use the above rules
app.use(router)

app.listen(PORT, () => {
  console.log(`SSR running on port ${PORT}`)
})

Two details worth a note. The import says '../src/App.jsx' with the extension, because ignore-styles (which we install below) also registers a handler for .css files, and without the extension Node.js would resolve ../src/App to App.css before App.jsx. And the route is router.get('/', ...): older versions of this post used router.use('^/$', ...), a regex-like string that Express 5 no longer matches.

Now, in the client application, in your src/main.jsx, replace createRoot().render() with hydrateRoot from react-dom/client. The old ReactDOM.render() and ReactDOM.hydrate() APIs were removed in React 19. Hydration attaches event listeners to the markup the server already sent:

import { hydrateRoot } from 'react-dom/client'
import App from './App'

hydrateRoot(document.getElementById('root'), <App />)

All the Node.js code needs to be transpiled by Babel, as server-side Node.js code does not know anything about JSX, and our .cjs file uses import statements that need to become require() calls. Vite only builds the client bundle here.

Install these 4 packages:

npm install @babel/register @babel/preset-env @babel/preset-react ignore-styles

ignore-styles is a Babel utility that will tell it to ignore CSS files imported using the import syntax.

Let’s create an entry point in server/index.cjs:

require('ignore-styles')

require('@babel/register').default({
  ignore: [/(node_modules)/],
  presets: ['@babel/preset-env', ['@babel/preset-react', { runtime: 'automatic' }]]
})

require('./server.cjs')

The .default is there because @babel/register 8 is an ES module, so require() gives you its namespace (the same line works with Babel 7 too). The runtime: 'automatic' option makes JSX work without an import React line in every component, which is how Vite’s App.jsx is written.

Build the React application, so that the dist/ folder is populated:

npm run build

and let’s run this:

node server/index.cjs

I said this is a simplistic approach, and it is:

So while this is a good example of using ReactDOMServer.renderToString() and hydrateRoot to get this basic server-side rendering, it’s not enough for real world usage.

Server Side Rendering using libraries

SSR is hard to do right, and React has no de-facto way to implement it.

It’s still very much debatable if it’s worth the trouble, complication and overhead to get the benefits, rather than using a different technology to serve those pages. This discussion on Reddit has lots of opinions in that regard.

When Server Side Rendering is an important matter, my suggestion is to rely on pre-made libraries and tools that have had this goal in mind since the beginning.

In particular, I suggest Next.js and Gatsby.

If you go the Next.js route and wonder whether a page should be static, ISR, SSR or client-rendered, my free Next.js rendering chooser helps you decide.

Tagged: React · All topics

Want me to talk about your product? You can sponsor this site.

~~~

Related posts about react: