This text provides a hands-on guide for creating a server-side rendering (SSR) React app using Create React App, ReactDOMServer, and other related tools.
Abstract
The guide begins by explaining the terminologies related to server-side rendering, client-side rendering, single-page applications, and universal (isomorphic) JavaScript. It then provides step-by-step instructions on how to deploy the production build with Express and build SSR inside the Express server. The process involves using ReactDOM.hydrate() or ReactDOM.hydrateRoot() to display server-rendered markup, using ReactDOMServer object to render components to static markup, and handling page-specific requirements. The guide also mentions the challenges and caveats of routing and data fetching in SSR. Finally, the guide concludes by summarizing the steps to set up SSR for Create React App and highlighting the benefits of static rendering.
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.
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 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, along with babel-plugin-transform-assets.
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.
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:
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] torestart at anytime, 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)
atModule._compile (internal/modules/cjs/loader.js:1121:27)
at Object.Module._extensions..js (internal/modules/cjs/loader.js:1177:10)
atModule.load (internal/modules/cjs/loader.js:1001:32)
atFunction.Module._load (internal/modules/cjs/loader.js:900:14)
atFunction.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:
<divclass="App"data-reactroot=""><headerclass="App-header"><imgsrc="static/media/logo.5d5d9eef.svg"class="App-logo"alt="logo"/><p>Edit <code>src/App.js</code> and save to reload.</p><aclass="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.
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.
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 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.
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:
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.
Thanks for reading. I hope this was helpful. You can see my other Medium publications here.