Server-Side Rendering in React 18
React is a library commonly used for developing Single Page Applications (SPAs) that rely heavily on JavaScript.
In fact, when a request is made from a browser, React returns an empty HTML page and the content is actually loaded using JavaScript after the initial page load.
Although this approach works, there are a few significant drawbacks to consider.
- Bad for Search Engine Optimization (SEO) as the initial HTML returned by React is empty, making it difficult to rank on search engines.
- The waiting time for the initial render of the page can be longer due to the fetching of large JavaScript bundles.
To counter these issues, we can resort to Server-Side Rendering (SSR).
Check out the code on GitHub if you want to jump directly to the code https://github.com/rajgaur98/react-ssr/tree/main .
Server-Side Rendering
Server-Side Rendering (SSR) is a technique that renders a web page on the server and sends it back to the client.
SSR returns a fully rendered HTML page to the browser, which can help mitigate the problems mentioned earlier, such as poor SEO and longer waiting times for the initial page render.
Frameworks like NextJS already support SSR out of the box, but it is also possible to implement it from scratch. There are three steps involved in this process:
- Generating the HTML on the server
- Rendering the HTML received from the server on the client
- Adding JavaScript to the static HTML on the client (also known as Hydration)
This will become more clear once we start writing the code.
Setup
We will use ExpressJS for setting up our web server and of course React and ReactDOM for our front end.
Additionally, we will also need Babel and Webpack for transpiling and bundling our module code into scripts that browsers can understand.
To install the necessary dependencies, run the following command:
yarn add express react react-dom
To install the development dependencies, run the following command:
yarn add -D @babel/core @babel/preset-env @babel/preset-react @webpack-cli/generators babel-loader webpack webpack-cli
Creating the Front-end
We will store all of our front-end files in a client
folder within our root folder. This folder will contain a typical React application and nothing daunting.
Let’s create an index.jsx
file, which will serve as the root of our React app.
import React from "react";
import { hydrateRoot } from "react-dom/client";
import { App } from "./App.jsx";
hydrateRoot(document, <App />);
Note that we are using the hydrateRoot
function instead of the createRoot
function. This is because we will already have the HTML document from the back end, and we now only need to attach JavaScript to it.
Before we move on, let's understand more about hydration.
When we render our web pages on the server, the server outputs a plain HTML document that is static.
While this is suitable for showing the initial render to the user, the server does not attach event listeners or JavaScript to the code. It only returns a static page.
It is the front-end's responsibility to attach JavaScript to this server-rendered HTML. Therefore, the process of attaching JavaScript to this static, dry page is called Hydration.
So, as you can see from the last line of the code, we are utilizing the hydrateRoot function to attach JavaScript to the server-rendered document
using the App
component.
Now, let’s create the App component.
import React from "react";
export const App = () => {
return (
<html>
<head>
</head>
<body>
<div id="root">App</div>
</body>
</html>
);
};
It’s important to note that the whole HTML is included in the App component, as it will be rendered on the server and we want the server to return the complete HTML.
The reason for this is that the server can use the HTML structure to insert the necessary JavaScript and CSS imports in the form of script and link tags into the HTML.
For now, let’s focus on rendering this simple app on the server side, as we will add more complexities to the app later.
Managing Assets
Including assets such as CSS in server-side rendering can be done either by adding a link
tag in the head
of the HTML, or by using Webpack plugins.
For example, you can use css-loader
and MiniCssExtractPlugin
to extract CSS from .jsx
files and inject them as a link
tag. These plugins allow you to use CSS imports within .jsx
files as follows:
import React from "react"
import "../styles/main.css"
However, in this case, we have used a manual approach for injecting the sheets into the HTML, as shown below:
<head>
<link rel="stylesheet" href="styles/Root.css"></link>
</head>
For images, we have used static ExpressJS paths instead of importing them in .jsx
files.
<img src="optimus.png" alt="Profile Picture" />
If you prefer to import images into .jsx
, you can use the following Webpack solution:
module: {
rules: [
{
test: /\.(png|svg|jpg|jpeg|gif)$/i,
type: 'asset/resource',
},
],
},
Creating the Server
We’ll store our server files in a src
folder within our root directory. The server is straightforward, consisting of two files: index.js
and render.js
.
index.js
contains the standard Express code, with a single route.
const express = require("express");
const path = require("path");
const app = express();
const { render } = require("./render");
app.use(express.static(path.resolve(__dirname, "../build")));
app.use(express.static(path.resolve(__dirname, "../assets")));
app.get("/", (req, res) => {
render(res);
});
app.listen(3000, () => {
console.log("listening on port 3000");
});
Let’s also have look at the render.js
file, it is interesting.
import React from "react";
import { renderToPipeableStream } from "react-dom/server";
import { App } from "../client/App.jsx";
export const render = (response) => {
const stream = renderToPipeableStream(<App />, {
bootstrapScripts: ["client.bundle.js"],
onShellReady() {
response.setHeader("content-type", "text/html");
stream.pipe(response);
},
});
};
Notice that we are able to use ESM import
statements inside the render.js
file because we will transpile this later with babel
.
The React import at the top is necessary, otherwise, the web page will not be rendered and an error will be thrown.
We then import renderToPipeableStream
, which will be used to render our App
component to HTML.
The renderToPipeableStream
function takes two parameters: the App
component and options.
The bootstrapScripts
option is an array that contains the paths to scripts that will hydrate the HTML.
The client.bundle.js
script is the output bundle of our client/index.jsx
entry point. If you recall, the hydrateRoot
function is located inside client/index.jsx
.
The onShellReady
function is triggered after the initial HTML is ready. We can start streaming the HTML using stream.pipe(response)
to send it progressively to the browser.
This simple SSR app is now complete and ready to run! However, running all the code as is will result in errors. We’ll need to bundle the client and server code first.
Bundling the Modules
We'll need babel.config.json
and webpack.config.js
config files in the root folder to transpile and bundle our code.
// babel.config.json
{
"presets": ["@babel/preset-env", "@babel/preset-react"]
}
Babel will handle ECMAScript Module (ESM) imports, JSX, and other non-standard JavaScript code using the presets specified in the above file.
// webpack.config.js
const path = require("path");
const clientConfig = {
target: "web",
entry: "./client/index.jsx",
output: {
filename: "client.bundle.js",
path: path.resolve(__dirname, "build"),
},
module: {
rules: [
{
test: /\.(js|jsx)$/i,
loader: "babel-loader",
},
],
},
};
const serverConfig = {
target: "node",
entry: "./src/index.js",
output: {
filename: "server.bundle.js",
path: path.resolve(__dirname, "build"),
},
module: {
rules: [
{
test: /\.(js|jsx)$/i,
loader: "babel-loader",
},
],
},
};
module.exports = [clientConfig, serverConfig];
For the client config, we set the target
property to web
, the entry
point to client/index.jsx
, and the output
to client.bundle.js
.
For the server config, we set the target
property to node
, the entry
point to src/index.js
, and the output
to server.bundle.js
.
We’ll also add scripts to package.json
for building and running our code.
// package.json
{
// ...
"scripts": {
"start": "yarn build:dev && node build/server.bundle.js",
"build": "webpack --mode=production --node-env=production",
"build:dev": "webpack --mode=development",
"build:prod": "webpack --mode=production --node-env=production"
}
}
Now, when we run yarn start
, the client and the server code should be bundled inside the build
folder and our server should be up and running! If we make a request to http://localhost:3000
, we should see our simple app on the browser.
Inspecting the network tab, we should see the server-generated HTML from localhost:3000
. In the elements tab, we should be able to see <script src=”client.bundle.js” async=””></script>
tag inserted before the </body>
tag.
Suspense Support
Let’s start adding content to our app. We’ll include a sidebar, a blog page, and a “Related Blogs” component. To organize our code, we’ll refactor App.jsx
into separate components.
We’ll also refactor our HTML markup into a component called Html
. This means that the App
component should now look like the following:
import React, { lazy, Suspense } from "react";
import { Html } from "./Html.jsx";
import { Loading } from "./components/Loading.jsx";
const Sidebar = lazy(() =>
import("./components/Sidebar.jsx" /* webpackPrefetch: true */)
);
const Main = lazy(() =>
import("./components/Main.jsx" /* webpackPrefetch: true */)
);
const Extra = lazy(() =>
import("./components/Extra.jsx" /* webpackPrefetch: true */)
);
export const App = () => {
return (
<Html>
<Suspense fallback={<Loading />}>
<Sidebar></Sidebar>
<Suspense fallback={<Loading />}>
<Main></Main>
<Suspense fallback={<Loading />}>
<Extra></Extra>
</Suspense>
</Suspense>
</Suspense>
</Html>
);
};
Our app now has some interesting features. Let's break them down:
Sidebar
,Main
, andExtra
are standard React components, so we won't go into detail on their code for now.lazy
: This is used to lazy-load components. This means that they are only imported when they need to be rendered. This results in code splitting in Webpack, creating separate bundle files for each component in thebuild
folder. These files are loaded after theclient.bundle.js
bundle is loaded in the browser.Suspense
: This is a React component that handles concurrent data fetching and rendering. It waits for thelazy
components to load on demand and shows a loading indicator (using thefallback
prop) until they are ready.
In our case, this plays an important role in streaming HTML from the server. The HTML is sent progressively as a stream, with the Suspense
component waiting for components to load as needed.
For example, the HTML up to the root div
is sent first, then the Sidebar
component, then the Main
component, and finally the Extra
component.
This improves the user experience, as the user doesn’t have to wait for all components to load at once. Components are shown progressively as they become available.
To see this in action, you can artificially delay component loading in the App
component.
const Sidebar = lazy(
() =>
new Promise((resolve) => {
setTimeout(
() =>
resolve(
import("./components/Sidebar.jsx" /* webpackPrefetch: true */)
),
1000
);
})
);
const Main = lazy(
() =>
new Promise((resolve) => {
setTimeout(
() =>
resolve(import("./components/Main.jsx" /* webpackPrefetch: true */)),
2000
);
})
);
const Extra = lazy(
() =>
new Promise((resolve) => {
setTimeout(
() =>
resolve(import("./components/Extra.jsx" /* webpackPrefetch: true */)),
3000
);
})
);
Controlling the HTML stream
You can additionally control the HTML streaming with options in the renderToPipableStream
method.
You can use onAllReady
instead of onShellReady
option if you want to stream all the HTML at once when the complete page has loaded and not on the initial render.
const stream = renderToPipeableStream(<App />, {
bootstrapScripts: ["client.bundle.js"],
onAllReady() {
response.setHeader("content-type", "text/html");
stream.pipe(response);
},
});
The server side rendering can also be stopped so that rest of the rendering can happen on the client side. You can achieve this using the following:
const stream = renderToPipeableStream(<App />, {
bootstrapScripts: ["client.bundle.js"],
onShellReady() {
response.setHeader("content-type", "text/html");
stream.pipe(response);
},
});
setTimeout(() => {
stream.abort()
}, 10000)
Conclusion
That’s all, folks. I hope this article has helped you understand the complexities of server-side rendering of React components. Thank you for reading!
Useful Links