Thought leadership from the most innovative tech companies, all in one place.

A hands-on guide for a Server-Side Rendering React app

In the previous article, we described how to make a production build and deploy it to a server. Naturally, the next step is the server-side rendering. We are going to walk through the process by…

image

Image credit: Author

This article has been updated with React 18, Create React App 5, and React Router 6. The new article is A Hands-on Guide for a Server-Side Rendering React 18 App. In the previous article, we described how to make a production build and deploy it to a server. Naturally, the next step is the server-side rendering. We are going to walk through the process by converting Create React App to a server-side rendered application.

Terminologies

What is client-side rendering (CSR)?

It is a technology that a browser downloads the minimal HTML page, which uses JavaScript to render and fills the content. CSR may take longer for the initial page loading, but the subsequent loading would be faster. It off-loads the server and relies on the power of JavaScript libraries. However, it is hard for Search Engine Optimization (SEO) as there is no static content to be crawled upon.

What is server-side rendering (SSR)?

It is a technology that a browser downloads the complete HTML page, which has been rendered by the server. The advantage of SSR is for SEO. The initial page loading is faster. But it needs the full page reloading for the subsequent changes. This may overload the server.

What is single-page application (SPA)?

It is a an application that uses the client-side rendering. Instead of having a different HTML page per route, it renders each route dynamically and directly in the browser.

What is universal (isomorphic) JavaScript?

It is a Javascript application that runs on both the client and the server. It renders HTML on the client as SPA, and it also renders the same HTML on the server side and then sends it to the browser to display. We write React code for CSR. The same code base can be used for SSR. React is universal JavaScript. SSR exists before CSR. Today, it is revived with universal JavaScript. When SSR is mentioned today, it likely means SSR with universal JavaScript.

Create React App and CSR

Install Create React App, and run npm start. image From the Elements tab, it shows the JavaScript rendered HTML (JSX) for the spinning logo and some text information. This is a typical CSR, where HTML content is rendered by JavaScript. From the Network tab, we can read what is downloaded from the server. image The HTML’s body has a bunch of JavaScript files, but no actual content. It is hard for SEO to get any meaningful information.

Deploy the Production Build With Express

A hands-on guide for creating a production-ready React app sets up the foundation work for server-side rendering. Here is a brief recap. Create server/index.js as follows: Execute npm run build to create a production build. Then run nodemon server to deploy it with the Express server. From the Network tab, it shows what is retrieved from the server: There are 3 JavaScript files (lines 17 - 124, line 125, line 126) with the empty markup content (line 16). Hence, it is CSR.

Build SSR Inside the Express Server

There are 3 steps to build SSR inside the Express server.

Step 1: Use ReactDOM.hydrate() or ReactDOM.hydrateRoot()to display the server-rendered markup.

The following is a pre-React 18 solution, and it uses an older version of Create React App that uses serviceWorker. ReactDOM.hydrate()is similar to as ReactDOM.render(). It is used to hydrate a container whose HTML contents have been rendered by the ReactDOMServer object. Its syntax is ReactDOM.hydrate(element, container[, callback]), almost identical to ReactDOM.render(element, container[, callback]). Since ReactDOM.hydrate() is called on a node that already has the server-rendered markup, React will preserve it and only attach event handlers. This makes the initial load performant. ReactDOM.hydrate() is used in src/index.js: At line 7, ReactDOM.render() is replaced by ReactDOM.hydrate(). The following is a React 18 solution: _hydrate_ is replaced by _hydrateRoot_, which is exported from _react-dom/client_. Its syntax is _hydrateRoot(container, element)_. The new root provides concurrency improvement. It also uses a newer version of Create React App that uses reportWebVitals. ReactDOM.hydrateRoot() is used in src/index.js:

Step 2: Use ReactDOMServer object to render components to static markup.

React provides the [ReactDOMServer](https://reactjs.org/docs/react-dom-server.html) object to render components to static markup. It sends to the browser a page that has been populated with data. React code is universal JavaScript, which runs on both the client and the server. There are different packages and approaches to achieve SSR. @babel/register is one of the packages. It is installed as part of [devDependencies](https://medium.com/better-programming/package-jsons-dependencies-in-depth-a1f0637a3129), along with babel-plugin-transform-assets.

"devDependencies": {
  "@babel/register": "^7.11.5",
  "babel-plugin-transform-assets": "^1.0.2"
}

The following is SSR implementation in server/index.js. We do not use ES modules to write the code since @babel/register does not support compiling native Node.js ES modules on the fly. It is said that currently there is no stable API for intercepting ES modules loading. Note: From React 18, _renderToString_ still works but with limited Suspense support. React 18 revamps server-side APIs and put them in react-dom/server. These new APIs fully support Suspense on the server and Streaming SSR. At line 1, we set up Babel through the require hook, which automatically compiles files on the fly. Without this hook and the associated presets, we will encounter SyntaxError: Cannot use import statement outside a module.

$ nodemon server
[nodemon] 2.0.4
[nodemon] to restart at any time, enter `rs`
[nodemon] watching path(s): *.*
[nodemon] watching extensions: js,mjs,json
[nodemon] starting `node server`
/Users/fuje/app/react-app1/src/App.js:1
import React from "react";
^^^^^^SyntaxError: Cannot use import statement outside a module
    at wrapSafe (internal/modules/cjs/loader.js:1071:16)
    at Module._compile (internal/modules/cjs/loader.js:1121:27)
    at Object.Module._extensions..js (internal/modules/cjs/loader.js:1177:10)
    at Module.load (internal/modules/cjs/loader.js:1001:32)
    at Function.Module._load (internal/modules/cjs/loader.js:900:14)
    at Module.require (internal/modules/cjs/loader.js:1043:19)
    at require (internal/modules/cjs/helpers.js:77:18)
    at Object.<anonymous> (/Users/fuje/app/react-app1/server/index.js:3:13)
    at Module._compile (internal/modules/cjs/loader.js:1157:30)
    at Object.Module._extensions..js (internal/modules/cjs/loader.js:1177:10)
[nodemon] app crashed - waiting for file changes before starting...

At line 2, we set up two presets:

  • @babel/preset-env, a smart preset that uses the latest JavaScript without needing to micromanage which syntax transforms are needed for the target environments.
  • @babel/preset-react, a smart preset that automatically imports the functions that JSX transpiles to. Lines 3 - 14 are plugins for babel-plugin-transform-assets. It sets up rules on how to transform static media files. Without this, it throws SyntaxError: Unexpected token ‘<’ for the svg tag.
$ nodemon server
[nodemon] 2.0.4
[nodemon] to restart at any time, enter `rs`
[nodemon] watching path(s): *.*
[nodemon] watching extensions: js,mjs,json
[nodemon] starting `node server`
/Users/fuje/app/react-app5/src/logo.svg:1
<svg xmlns="<http://www.w3.org/2000/svg>" viewBox="0 0 841.9 595.3">
^SyntaxError: Unexpected token '<'
    at wrapSafe (internal/modules/cjs/loader.js:1071:16)
    at Module._compile (internal/modules/cjs/loader.js:1121:27)
    at Module._extensions..js (internal/modules/cjs/loader.js:1177:10)
    at Object.newLoader [as .js] (/Users/fuje/app/react-app5/node_modules/pirates/lib/index.js:104:7)
    at Module.load (internal/modules/cjs/loader.js:1001:32)
    at Function.Module._load (internal/modules/cjs/loader.js:900:14)
    at Module.require (internal/modules/cjs/loader.js:1043:19)
    at require (internal/modules/cjs/helpers.js:77:18)
    at Object.<anonymous> (/Users/fuje/app/react-app5/src/App.js:2:1)
    at Module._compile (internal/modules/cjs/loader.js:1157:30)
[nodemon] app crashed - waiting for file changes before starting...

At line 16, React is required. At line 17, ReactDOMServer is required. At line 18, the default export of src/App.js is required. We move app.use() to line 47, after app.get() (lines 25 - 45). Otherwise, app.use() will serve the static files, including index.html, for the root route, and the execution will not have a chance to reach the app.get() middleware. Line 26 displays the request URL that is invoked. For Create React App, they are listed as follows:

Request URL = /
Request URL = /static/css/main.519b5a55.chunk.css
Request URL = /static/media/logo.5d5d9eef.svg
Request URL = /static/js/main.fdf902fb.chunk.js
Request URL = /static/js/2.bc7ff9af.chunk.js
Request URL = /static/css/main.519b5a55.chunk.css.map
Request URL = /static/js/main.fdf902fb.chunk.js.map
Request URL = /static/js/2.bc7ff9af.chunk.js.map
Request URL = /manifest.json
Request URL = /logo192.png

Lines 27 - 29 ensures only the root route is rendered by app.get(). The static assets will skip to the next middleware, which is at line 47. At line 30, ReactDOMServer.renderToString(element) is used to generate HTML on the server. From the theory, it could be written as ReactDOMServer.renderToString(<App />). But that would throwSyntaxError: Unexpected token ‘<’.

$ nodemon server
[nodemon] 2.0.4
[nodemon] to restart at any time, enter `rs`
[nodemon] watching path(s): *.*
[nodemon] watching extensions: js,mjs,json
[nodemon] starting `node server`
/Users/fuje/app/react-app5/server/index.js:42
  const reactApp = ReactDOMServer.renderToString(<App />);
                                                 ^SyntaxError: Unexpected token '<'
    at wrapSafe (internal/modules/cjs/loader.js:1071:16)
    at Module._compile (internal/modules/cjs/loader.js:1121:27)
    at Object.Module._extensions..js (internal/modules/cjs/loader.js:1177:10)
    at Module.load (internal/modules/cjs/loader.js:1001:32)
    at Function.Module._load (internal/modules/cjs/loader.js:900:14)
    at Function.executeUserEntryPoint [as runMain] (internal/modules/run_main.js:74:12)
    at internal/main/run_main_module.js:18:47
[nodemon] app crashed - waiting for file changes before starting...

Requiring @babel-register will not work for the file where it is required, but it will work for files that are required afterwards. Either move the code including ReactDOMServer.renderToString(<App />) to a separate file to be required, or simply use React.createElement(). Line 31 displays the server rendered markup code:

<div class="App" data-reactroot="">
  <header class="App-header">
    <img src="static/media/logo.5d5d9eef.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. Lines 34 - 44 read the content of index.html. If there is no error, the server generated markup (line 42) is rendered to the root tag, and then the final index.html responds to the initial loading. Execute nodemon server. From the Network tab, the downloaded script shows a server rendered markup. From the Elements tab, the following is the new body of the HTML: The body content has the full content, which can be used by SEO to get meaningful information. Although there are a bunch of JavaScript files, they are not executed. This can be proved by turning off JavaScript from the bowser. image This is SSR. Disabling JavaScript, the code continues working.

Step 3: Handle page specific requirements.

We made one-page React app work. How about an app with multiple routes? First, install react-router-dom as one of [dependencies](https://medium.com/better-programming/package-jsons-dependencies-in-depth-a1f0637a3129).

"devDependencies": {
  “react-router-dom”: “^5.2.0”,
  ...
}

Modify src/App.js as follows: Execute npm run build, and then run nodemon server.

Error: Invariant failed: Browser history needs a DOM
    at invariant (/Users/fuje/app/react-app5/node_modules/tiny-invariant/dist/tiny-invariant.cjs.js:13:11)
    at Object.createHistory [as createBrowserHistory] (/Users/fuje/app/react-app5/node_modules/history/cjs/history.js:273:16)
    at new BrowserRouter (/Users/fuje/app/react-app5/node_modules/react-router-dom/modules/BrowserRouter.js:11:13)
    at processChild (/Users/fuje/app/react-app5/node_modules/react-dom/cjs/react-dom-server.node.development.js:2995:14)
    at resolve (/Users/fuje/app/react-app5/node_modules/react-dom/cjs/react-dom-server.node.development.js:2960:5)
    at ReactDOMServerRenderer.render (/Users/fuje/app/react-app5/node_modules/react-dom/cjs/react-dom-server.node.development.js:3435:22)
    at ReactDOMServerRenderer.read (/Users/fuje/app/react-app5/node_modules/react-dom/cjs/react-dom-server.node.development.js:3373:29)
    at Object.renderToString (/Users/fuje/app/react-app5/node_modules/react-dom/cjs/react-dom-server.node.development.js:3988:27)
    at /Users/fuje/app/react-app5/server/index.js:42:35
    at Layer.handle [as handle_request] (/Users/fuje/app/react-app5/node_modules/express/lib/router/layer.js:95:5)

Unfortunately, BrowserRoute uses the HTML5 pushState history API under the hood, which is not supported by Node.js. For SSR, [StaticRouter](https://reactrouter.com/core/guides/static-routes) should be used in universal JavaScript. However, StaticRouter is currently an alpha software. We are developing a package to work with static route configs and React Router, to continue to meet those use-cases. It is under development now but we’d love for you to try it out and help out. React Router Config Another choice is MemoryRouter, which keeps the URL history in memory (does not read or write to the address bar). It is useful in tests and non-browser environments. Here is the src/App.js: Line 35 and line 39 use MemoryRouter. Execute npm run build, and then run nodemon server. Go to http://localhost:8080, we see the following page: image The app works, although URL in the address bar will not update. Routing works with a caveat. There are also other things to be taken care of, such as data fetching, Redux, etc. The work at the server side is not as straightforward as the client side. Each page may need specific care based on its requirement.

Conclusion

We have showed how to set up SSR for Create React App. These are the steps:

  • Use ReactDOM.hydrate() or ReactDOM.hydrateRoot() to display the server-rendered markup.
  • Use ReactDOMServer object to render components to static markup.
  • Handle page specific requirements We use ReactDOMServer object to render components to static markup. It can be used in SSR and static rendering. SSR happens on-demand when a file is requested. A static rendering happens once at build time. Both of them are SEO friendly. If the page includes only static data, the static rendering is faster. However, if the response is dynamic, SSR is a better choice. Sometimes, a hybrid approach may be the best for the situation. The static rendering is beyond the scope of this article. Remix is a full-stack web framework that focuses on the user interface and works back through web fundamentals. It includes SSR and other features out of box, boilerplate-free.



Continue Learning