Server-Side Rendering a React 18 App with Express and Babel
Convert a Create React App project to server side rendering with Express and Babel: renderToString, hydrateRoot, MemoryRouter, then how Remix and Gatsby do SSR.
This article is published in English.
Sources: A Hands-on Guide for a Server-Side Rendering React 18 App
Server-side rendering for React has shifted a lot since React 18 arrived. The setup below targets React 18, Create React App 5 and React Router 6, and starts from a stock Create React App project that gets converted step by step. The rendering has to run on a server, so a Node process is needed. Plain Node works, but nodemon is handy during development because it restarts the app whenever a file changes. A global install looks like this:
% npm install -g nodemon
CSR versus SSR in a few lines
With client-side rendering (CSR), the browser receives a nearly empty HTML page and JavaScript fills in the content. The first load can be slow, but later navigation is fast, and the server carries little load because JavaScript libraries do the work. The weakness is search engine optimization, since a crawler finds no static content to read.
With server-side rendering, the browser instead receives a complete HTML page that the server already rendered. SEO benefits, and the first paint is quick. The tradeoff is that later changes tend to need a full page reload, which can pile load onto the server.
A few related terms help here:
- A single-page application (SPA) uses CSR. Rather than serving separate HTML per route, it draws each route dynamically inside the browser.
- Universal JavaScript runs on both client and server. It renders HTML in the browser like an SPA, renders the same HTML on the server and ships it to the browser.
- React code is universal by nature, so the code written for CSR can drive SSR with the same code base.
SSR is actually older than CSR. It has returned in a modern form built on universal JavaScript, and that's what people usually mean by SSR now.
A baseline CSR app
Create a fresh project:
% yarn create react-app react-ssr
% cd react-ssr
Start it with npm start. In the browser's Elements tab, the spinning logo and the text appear as HTML produced by JavaScript.
That's textbook CSR: scripts generate the HTML content. The Network tab shows what the server really sent.
The body holds a JavaScript bundle and no actual content, so a crawler has almost nothing meaningful to index.
Serving the production build
SSR needs a production build, so the first job is a small Express server that serves the compiled React output. The production-ready React setup covered earlier lays the groundwork. The server file comes in two flavors. One is CommonJS, server/index.js, and the other is an ES module, server/index.mjs.
Run npm run build to produce the build, then start the server with nodemon server or nodemon server/index.mjs. The Network tab now shows one JavaScript bundle (line 12) and empty markup (line 17). Nothing server-rendered is happening yet, so it's still CSR.
The three steps for SSR in Express
Making SSR work inside the Express server takes three steps.
Step 1: hydrate on the client. ReactDOM.hydrateRoot() resembles ReactDOM.createRoot(), with the signature hydrateRoot(container, element[, options]). It's meant for a container whose HTML was already produced by ReactDOMServer. Because the node already holds server-rendered markup, React keeps it and only attaches event handlers, which keeps the initial load fast. The call sits at line 7 of src/index.js.
Step 2: render on the server. React ships the ReactDOMServer object to turn components into static markup, so the browser gets a page already filled with data. Since React code is universal JavaScript, the same components run on either side.
Step 3: handle page-specific needs. Each route may demand its own attention, as later sections show.
Making Node understand JSX
Several packages can get SSR running. @babel/register is among the simplest. Install it together with babel-plugin-transform-assets:
% yarn add -D @babel/register
% yarn add -D babel-plugin-transform-assets
Both land in devDependencies:
"devDependencies": {
"@babel/register": "^7.18.9",
"babel-plugin-transform-assets": "^1.0.2"
}
@babel/register compiles files on the fly, and it can be set up to transform JSX and assets. One limit applies: it can't compile native Node.js ES modules on the fly, because no stable API exists yet for hooking ES module loading. That's why the SSR experiments here use CommonJS files.
React 18 reworked its server APIs and moved them under react-dom/server. renderToString still functions, though its Suspense support is limited. The newer APIs, renderToPipeableStream among them, fully support Suspense on the server along with streaming SSR.
Walking through the SSR server
The updated server/index.js is explained below by line number.
- Lines 1 to 15 configure Babel through the
requirehook, so files compile as they load. - Line 2 picks two presets.
@babel/preset-envlets code use the latest JavaScript without hand-picking syntax transforms for each target environment.@babel/preset-reactimports the functions JSX compiles to, automatically. - Lines 3 to 14 set up
babel-plugin-transform-assets, which defines how static media files get transformed. Without it, ansvgtag causesSyntaxError: Unexpected token '<'. - Line 16 requires
React, line 17 requiresReactDOMServer, and line 18 requires the default export ofsrc/App.js.
Order matters here. app.use() at line 47 has to run after app.get() in lines 25 to 45. If it came first, it would serve the static files, index.html included, for the root route, and app.get() would never get a turn.
Line 26 logs each request URL. For a Create React App build, the list looks like this:
Request URL = /
Request URL = /static/js/main.b92cc87b.js
Request URL = /static/css/main.073c9b0a.css
Request URL = /static/media/logo.06e73328.svg
Request URL = /static/css/main.073c9b0a.css.map
Request URL = /static/js/main.b92cc87b.js.map
Request URL = /static/media/logo.6ce24c58023cc2f8fd88fe9d219db6c6.svg
Request URL = /favicon.ico
Request URL = /manifest.json
Request URL = /logo192.png
Lines 27 to 29 make sure only the root route is rendered by app.get(), and static assets fall through to the next middleware at line 47.
Line 30 calls ReactDOMServer.renderToString(element) to produce HTML on the server. Writing ReactDOMServer.renderToString(<App />) directly would be natural, but JSX would then need to live in a different file from the one where @babel-register is configured, hence the separate element.
Line 31 prints the markup the server generated:
<div class="App"><header class="App-header"><img src="static/media/logo.06e73328.svg" class="App-logo" alt="logo"/><p>Edit <code>src/App.js</code> and save to reload.</p><a class="App-link" href="https://reactjs.org" target="_blank" rel="noopener noreferrer">Learn React</a></header></div>
Line 33 loads the production index.html, and lines 34 to 44 read its contents. If there's no error, the generated markup (line 42) is injected into the root tag, and the finished index.html becomes the answer to the first request.
Checking that it works
Launch nodemon server and look at the Network tab again. The downloaded document now carries server-rendered markup. Lines 17 to 27 of the body contain the whole page, which gives SEO tools something real to read.
For a sharper test, open chrome://settings/content/javascript and switch JavaScript off.
The page keeps rendering without JavaScript.
That's SSR.
Adding routes
A one-page app is easy, so the next question is multiple routes. Begin by installing react-router-dom:
% yarn add react-router-dom
It joins dependencies:
"dependencies": {
"react-router-dom": "^6.11.2"
}
Then edit src/App.js. Line 1 imports React, which SSR needs. Line 2 imports MemoryRouter in place of BrowserRouter. The browser router relies on the HTML5 pushState history API, which Node.js doesn't provide. MemoryRouter holds URL history in memory without touching the address bar, which suits tests and non-browser environments. StaticRouter from react-router-dom/server is another option, though it isn't meant for complicated routes. Lines 34 to 40 put MemoryRouter to use.
Build again with npm run build, start nodemon server and visit http://localhost:8080.
SSR works, with one quirk: the address bar URL doesn't update.
Where hand-rolled SSR gets painful
Routing works with that caveat, and other concerns still wait, such as data fetching and Redux. Server-side work is less straightforward than the client's, and each page may need specific handling.
There's no need to solve all of it by hand, because several frameworks and libraries include SSR. Remix is a full-stack web framework that starts from web fundamentals and works up to the user interface, with SSR and more included and no boilerplate. Its client code calls hydrateRoot at line 4, and its server code calls renderToPipeableStream at line 4.
React 18 introduced renderToPipeableStream(element, options). Give it a React element and it produces the initial HTML, handing back a stream. The stream has pipe(res) for sending the output on and abort() for cancelling the request. Suspense is fully supported, and HTML with delayed content streams without late "pop-in", as inline <script> tags fill in the gaps afterward.
On the Network tab, the Remix index page's body already holds the full page, so it's SSR.
Gatsby, Next.js, NuxtJS, Quasar and SvelteKit also offer SSR and related features out of the box with no boilerplate, though each defines SSR a little differently. Gatsby's index page shows the same full body on the Network tab.
Gatsby is a fast, sturdy static site generator that uses React to render static content, and it lets you change static HTML while it's being server-rendered.
SSR or static rendering
When a page contains only static data, static rendering is quicker. If the response is dynamic, SSR is the better pick. Sometimes a hybrid fits best. SSR happens on demand, whenever a file is requested, while static rendering happens once at build time. Either is SEO-friendly, because the HTML body holds the full content in both cases.
Recap
Setting up SSR for Create React App comes down to three pieces:
- Display the server-rendered markup with
ReactDOM.hydrateRoot(). - Render components to static markup with the
ReactDOMServerobject, whose APIs serve both SSR and static rendering. - Handle whatever each page specifically needs.
For a real project, the sensible choice is a framework or library that already does this.
Want to Connect?
If you are interested, check out my directory of web development articles.