Server-Side Rendering in React 18

Without NextJS or other frameworks.

Rajkumar Gaur
SliceOfDev
Published in
8 min readFeb 3, 2023

--

Photo by Shiro hatori on Unsplash

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).

Checkout 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:

  1. Generating the HTML on the server
  2. Rendering the HTML received from the server on the client
  3. 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, and Extra 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 the build folder. These files are loaded after the client.bundle.js bundle is loaded in the browser.
  • Suspense: This is a React component that handles concurrent data fetching and rendering. It waits for the lazy components to load on demand and shows a loading indicator (using the fallback 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

Find more content like this at SliceOfDev.com

--

--