Let’s build a React from scratch: Part 4— Server Side Rendering and its Challenges

Arindam Paul
16 min readApr 6, 2022

--

Disclaimer: The content below is just for learning and have some key insights while using framework like React. While the inspiration is React, the idea is to deliver the core concepts of what it takes to build a library like React at its most basic level.

In the first three parts of the series, we learned how a library like React actually works in the Browser, by building a similar little library of our own which pretty much in a very simplified manner, visualize the core concepts like Virtual DOM, Renderer, State Management, Suspense, Concurrent Mode and more. Today, in this last part of the series, we will take it even further by introducing Server Side Rendering. Even though all big full-stack frameworks like Next.js, Remix do provide SSR and many other functionalities out of the box, it’s important to see how you can get SSR up and running yourself and the challenges that come along with it.

🫐 What is SSR (Server-side rendering?) and Why? 🍇

Let’s first understand what is server-side rendering and why we might need it. Most of the web applications these days are primarily written in JavaScript and hence do most of the heavy lifting on the client-side. Frameworks like React, Angular and Vue, by default, render the contents on the client-side inside Browser once all related code is delivered. This is client-side rendering, where the browser gets a bundled JavaScript code, parses it then executes and renders the resulting HTML content on the screen.

Typical Client Side rendering

On the other hand, server-side rendering is where the markup or HTML is generated on the server and sent as is to the client browser for it to render the content. Basically, the difference lies in where the HTML is generated. Server-side rendering has been the de facto way for a long time before frontend frameworks came into play. They still have advantages like -

  1. Lesser load times as the user doesn’t have to wait for the JavaScript to get parsed and executed in order to get content on the screen.
  2. Better SEO, because the crawlers can better index your pages. Even though, search engine crawlers are catching up to the frontend frameworks as well.

Note: SSR in general increases the amount of work happens on your server. So, with increase in traffic, you will have to keep up compute power to serve your customers really fast, else the benefits typically overshoots the overhead and latency caused by SSR.

Server-Side Rendering

🥞 How SSR Works: Hydration 🧇

Fundamental idea is to reduce round trips to the Server and defer loading big bundles of JS, fetch data to initiate the App. Instead, we can build the entire markup with Data on the Server and then send it to the client, we will be able to show content to the user much faster.

In order to build the markup on Server Side, remember we don’t have a browser at hand and this will be a very important consideration as you will see in the sections below. This has significant limitations on what we can do on server and how to keep things in sync between server and client.

React docs: If you call ReactDOM.hydrateRoot() on a node that already has this server-rendered markup, React will preserve it and only attach event handlers, allowing you to have a very performant first-load experience. And if the DOM sent from server matches with client generated one, it will avoid re-Render cycles altogether.

React expects that the rendered content is identical between the server and the client. But, it can patch up differences in text content. Mismatches must be treated as bugs and should be fixed. In development mode, React warns about mismatches during hydration.

If a single element’s attribute or text content is unavoidably different between the server and the client (for example, a timestamp), we can silence the warning by adding suppressHydrationWarning={true} to the element. But, it should not be overused!

🍱 Setting Things up for our App 🍛

In this post, we will go over a lot of ideas and concepts related to Server-Side rendering, so I will keep the setup as minimal as it can be. We start off with a simple create-react-app template in Stackblitlz. So this will be our starting point in terms of code.

As a basic setup, we will also add react-router-dom and add a few routes to work with, chances are in a real-world application you will be mostly working with multiple pages with routing involved and it's better to see how routing and navigation also play out with SSR. so our initial code for this will look like,

// App.jsimport React from 'react';
import { BrowserRouter, Routes, Route, Link } from 'react-router-dom';
import { Home } from './Pages/Home';
import { Articles } from './Pages/Articles';
import { About } from './Pages/About';

import './style.css';
export default function App() {
return (
<BrowserRouter>
<h1>SSR Example</h1>
<ul>
<li>
<Link to="/">Home</Link>
</li>
<li>
<Link to="/about">About</Link>
</li>
<li>
<Link to="/articles">Articles</Link>
</li>
</ul>
<Routes>
<Route path="/" exact element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="/articles" element={<Articles />} />
</Routes>
</BrowserRouter>

);
}
// Pages/About.js
import React from 'react';
export const About = () => {
return <h1>About</h1>;
};
// Pages/Articles.js
import React from 'react';
export const Articles = () => {
return <h1>Articles</h1>;
};
// Pages/Home.js
import React from 'react';
export const Home = () => {
return <h1>Home</h1>;
};

And that gives us a good and simple place to really get started with navigation for Server-side rendering. This is the code reference to get started.

🎨 First SSR Page Render 🎯

With the setup above, we are all set for diving into our first SSR page served from Server. For this, we will express as your backend server and of course typescript to help us out with some JSX syntax. Let’s install them as dependencies and start with a simple server.tsx file.

// server.tsx
import express from 'express';
import React from 'react';
import { renderToString } from 'react-dom/server';
const app = express();app.get('/*', (req, res) => {
const reactApp = renderToString(<h1>Hello from the Server</h1>);
return res.send(
`<html>
<body>
<div id="root"> ${reactApp}</div>
</body>
</html>

`
);
});
app.listen(3000, () => {
console.log('server is running');
});

Here, we are making use of the renderToString() method provided by react-dom/server, which takes a React Component and renders it as if it's a browser but in string format. Basically, you will get the HTML markup your react app would generate on the client-side here on the server. But, remember all this without a Browser. (which has interesting limitations we will discuss in our next sections).

This server.tsx we can’t be run using node directly, for this we will use typescript, so the typescript setup for compiling this for node looks something like this.

npm i -D typescript
npx typescript --init
// this creates the tsconfig.json, in which our settings are highlighted to keep our code minimal for this post."module": "commonjs",
"jsx": "react",
"strict": false,

With this setup, we can now compile our server.tsx to server.js using tsc compiler. and then run our first SSR app using node server.js

First SSR in our express app

What we see here, is that the HTML response from Server has everything that our React app would render on Browser. Kudos! this is the first moment of true server-side rendering.

Code till this section can be found here.

🍺 Routing Challenges for SSR 🍾

In this section let’s prepare our real-world app (with routing) for Server-Side rendering. You might think, we will just import our app, call renderToString()and be done with it. But it’s not that simple and won’t work just like that. But to see why by giving it a shot.

Also, at this point please note, we need to have all our pages in commonjs module format as when we import and by default only .ts and .tsx files are handled by Typescript. So, compiling using tsc will only compile those files and all subsequent files with .js extensions will not be touched. So, in order to set up our code (all of it) for SSR, we will mark our pages with .tsx extensions and use tsc to compile them to commonjs format which node can run easily. (We can always go the module route as well by setting type: "module" in package.json but in order to keep parity with create-react-app I am not touching it for this post).

So our project at this point looks like this.

Preparing all files for SSR

Now, if you try to run our server.js it will run successfully, but while rendering on the server (express) to serve our App for the browser, it will get the following error.

document is not present in server environment

Now, you can start seeing the intricacies that are involved with Server Side rendering, because we are using BrowserRouter for our App, which of course expects a browser env to work (window, document) etc. renderToString() fails as it doesn’t have any of those globals present on the server.

In order to solve the problem, this is where we will split our entry points at a top-level one for the client and one for the server. We will create a separate entry file for the server called index.server.tsx which looks like this.

import * as React from 'react';
import ReactDOMServer from 'react-dom/server';
import { StaticRouter } from 'react-router-dom/server';
import App from './App';export default function render(url: string) {
return ReactDOMServer.renderToString(
<React.StrictMode>
<StaticRouter location={url}>
<App />
</StaticRouter>
</React.StrictMode>
);
}

As you can see here, for the SSR we are not using the BrowserRouter anymore, we will use StaticRouter provided by react-router-dom/server which understands the server environment and doesn’t use any browser-specific logic or globals.

But, for now, in order to finish our work, we need to now use this entry point correctly in our main server.tsx file.

// server.tsx
import express from 'express';
import React from 'react';
import render from './src/index.server';
const app = express();app.get('/*', (req, res) => {
const reactApp = render(req.url);
return res.send(
`<html>
<body>
<div id="root"> ${reactApp}</div>
</body>
</html>
`
);
});
app.listen(3000, () => {
console.log('server is running');
});

And sure enough, when we compile and run this, we get a proper server-side rendering without issues using node server.js (of course, after compiling our project with npx tsc ).

Our React App with SSR

You can, the markup served from Server has all HTML prebuilt. But our app at this point is not interactive. Even though navigation will work, which might seem like everything is working, if you see the network tab, for each navigation we will make a server-side request which will again be served fresh. Take a look,

Static Navigation

Initially, with Client-Side rendering, all these navigations were handled on the client-side without explicit navigation to the server, once the app is completely loaded. To make my point that this app is not interactive, let’s add a bit of state to the <Home/> component and see that doesn’t work because there is no React on the web page (no JS) for that matter, it's behaving like a static site. Let’s see what I mean,

// ./Pages/Home.tsximport React, {useState} from 'react';export const Home = () => {
const [name, setName] = useState("World");
return (
<>
<h1>Home</h1>
<h2>State Value: {name}</h2>
<input
type="text"
value={name}
onChange={e => setName(e.target.value)} />

</>
);
};

And what we see as a result of this when we serve this page like SSR is,

SSR without interactivity

You can see clearly, it has the initial HTML fully rendered and served from our Server (network response) which has the initial state value of nameas World , but there is no interactivity. If I change the input value it doesn’t reflect on the UI and the reason is obvious, there is no client-side JS (React) handling these interactions for us. Let’s see how to add that in the next section.

Code till this section can be found here.

🚀 Client-side Hydration 🛸

Now, our stage is fully set up for handling the hydration part of our already served SSR page.

Fundamentally, all we aim to achieve is here is once the page is served it should become interactive as if a regular react app with client-side rendering would behave.

Basically, we tell React to attach event handlers to the HTML to make the app interactive. This process of rendering our components and attaching event handlers is known as “hydration”. It is like watering the ‘dry’ HTML with the ‘water’ of interactivity and event handlers. After hydration, our application becomes interactive, responding to clicks, and so on.

In fact, this is where we will make use of .hydrate() call from react-dom. It is a technique used that is similar to rendering, but instead of having an empty DOM to render all of our react components into, we have a DOM that has already been built, with all our components rendered as HTML.

If the DOM served matches with the DOM our client-side JS would build, React won’t re-render our App and will just attach the event listeners, making things even more efficient.

So, we modify our original client-sideindex.tsx entry point which looks something like this.

import * as React from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter } from 'react-router-dom';
import App from './App';ReactDOM.hydrate(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>,
document.getElementById('root')
);

Note that, we have now used the .hydrate() call of react-dom and not the typical .render() function.

Let’s also modify our main public/index.html file with a placeholder string like{{APP}} which we will use in our server-side rendering, to replace our SSR generated markup before serving it to the Browser/Client.

// ./public/index.html<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<div id="root">{{APP}}</div>
</body>
</html>

With our client-side index.tsx and index.html set properly, let’s prepare our real React App to be served from the server by building it first, remember we will use this build folder to serve from our express app so that everything gets loaded as expected. (like normal React App without SSR)

prepping React app for Hydration

Now, we have our React app built to be served for production and let’s make use of this in our express based server file for putting everything together. So, in our server.tsx file,

  • First, we need to be able to serve files from build folder as a regular web server.
  • Second, we don’t want to serve index.html from the build folder, we want to generate that on the fly and serve it from our express app itself, with SSR contents.

Let’s look at our server.tsx code which does all these here,

import express from 'express';
import React from 'react';
import render from './src/index.server';
import {readFileSync} from 'fs';
const app = express();
const templateFile = './build/index.html';
const templateHTML = readFileSync(templateFile, 'utf-8');
app.use(express.static('./build', { index: false }));app.get('/*', (req, res) => {
const reactApp = render(req.url);
const response = templateHTML.replace("{{APP}}",reactApp);
return res.send(
response
);
});
app.listen(3000, () => {
console.log('server is running');
});

In this example, we serve our build folder without the index.html by express.static(‘./build’, { index: false }) portion. Next, we read our built, index.html into our templateHTML which gets processed before serving by replacing our {{APP}} portion with the actual SSR generated content via templateHTML.replace(“{{APP}}”,reactApp); . Now, let’s compile our server.tsx and try running our server.

npx tsc
node server.js

And Voilà! we have a full react app that loads everything prepared from a server and then works hydrates on the client-side and provides interactivity. You can see me changing the state here and it reflects immediately on the UI.

React App with SSR and Hydration :)

Also, note, because we have now hydrated the BrowserRouter as well, as we navigate to other pages, it doesn’t fetch it from the server as client-side routing is enough and takes effect. Check as I click on the page links, there are no new network calls here.

Navigation doesn’t refresh the whole page as expected

Code for this section can be found here.

🪁 Styling Challenges for SSR (CSS Modules) 🤿

Let’s now see what it takes to style our app. Specifically, using CSS modules. Well purely on the client-side it's obvious and simple, whatever style you import and apply takes effect during the render cycle. Let’s take an example of a CSS-module usage for our Home component.

// ./Pages/home.module.css
.header {
color: green;
font-size: 3rem;
}
// ./Pages/Home.tsx
import React, {useState} from 'react';
import styles from './home.module.css';
export const Home = () => {
const [name, setName] = useState("World");
return (
<>
<h1 className={styles.header}>Home</h1>
<h2>State Value: {name}</h2>
<input
type="text"
value={name}
onChange={e => setName(e.target.value)} />
</>
);
};

Because we are using typescript, we need a typescript CSS module plugin so that, typescript understands a CSS module and knows how to import it.

npm install -D typescript-plugin-css-modules// in tsconfig.json
"plugins": [{ "name": "typescript-plugin-css-modules" }]
// top level we need a geenric typings for CSS module so create a file named global.d.ts right at the root folder// global.d.ts
declare module '*.module.css' {
const classes: { [key: string]: string };
export default classes;
}

Now, let’s compile npx tsc and run our react app in dev mode npm start and you will see our Home page has dynamically generated a random class name and attached the CSS properties to it. Code Reference

CSS Module in Action

But if we try to use our Server-side rendering for the same, we will get this error while running the code node server.js

Error on SSR

Of course, we can fix this with webpack configs, but I am not touching any of the webpack default configs here. We will keep it simple, let’s use another similar technique by usingstyled-components package to do the same and it will be much simpler to handle SSR. Let’s see how to achieve this.

npm install --save styled-components// ./Pages/Home.tsx
import React, {useState} from 'react';
import styled from 'styled-components';
const Title = styled.h1`
color: green;
font-size: 3rem;
`;
export const Home = () => {
const [name, setName] = useState("World");
return (
<>
<Title>Home</Title>
<h2>State Value: {name}</h2>
<input
type="text"
value={name}
onChange={e => setName(e.target.value)} />
</>
);
};

Simply put, with this, when we build our app, the class names are generated during build time itself and server.tsx doesn’t complain anymore. If we run node server.js after compilation, we will see it in action along with SSR.

➜  node-apk8dx node server.js
server is running
styled-components CSS Module in SSR

What we see is it generates the random classNameBut, please note that the actual CSS properties are not coming as a part of SSR. But, how do we get the actual styles? as a part of SSR. (Code reference)

We can do this by extracting the styles generated during the server-side rendering and putting them on our page during our page construction itself. styled-components make this very smooth with the helper ServerStylesheet from styled-components . Let’s see how we might want to go about implementing that.

// ./src/index.server.tsximport * as React from 'react';
import ReactDOMServer from 'react-dom/server';
import { StaticRouter } from 'react-router-dom/server';
import { ServerStyleSheet } from 'styled-components';
import App from './App';export const sheet = new ServerStyleSheet();export default function render(url: string) {
return ReactDOMServer.renderToString(
sheet.collectStyles(
<React.StrictMode>
<StaticRouter location={url}>
<App />
</StaticRouter>
</React.StrictMode>
)
);
}

We just like our generated HTML, we need a place where our extracted styles as well somewhere on the index.html . For this, we will need a placeholder to be replaced in our HTML. Just like before, we put a placeholder{{STYLE}} which we can then replace with generated CSS while doing SSR. Let’s see this in action,

// ./public/index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
{{STYLE}}
</head>
<body>
<div id="root">{{APP}}</div>
</body>
</html>

And, in our main server.tsx let’s use the extracted CSS and place it in this placeholder {{STYLE}} location.

// server.tsximport express from 'express';
import React from 'react';
import render, { sheet } from './src/index.server';
import { readFileSync } from 'fs';
const app = express();
const templateFile = './build/index.html';
const templateHTML = readFileSync(templateFile, 'utf-8');
app.use(express.static('./build', { index: false }));app.get('/*', (req, res) => {

const reactApp = render(req.url);
const response = templateHTML
.replace("{{APP}}", reactApp)
.replace("{{STYLE}}", sheet.getStyleTags());
return res.send(
response
);
});
app.listen(3000, () => {
console.log('server is running');
});

With these changes, now we have our CSS loaded as a part of the Server served file itself and will work without any hydration. See, the content we get from the server for this. Notice, the <style> tag in the head section itself which has all the styles needed for this page.

SSR with extracted Styles for Smooth Rendering of our CSS

🧸 Conclusion🪆

Well, let’s take a break now. We have done a lot in this post. We went from a vanilla create-react-app, to routing support to SSR with routing and finally styling using CSS modules and including that on our SSR app. While I wanted to cover more in-depth areas on SSR like,

  • Data loading Challenges for SSR (useEffect() hooks preloading)
  • Nested or dependent data loading during SSR (multi-pass render)
  • Streaming Hydration Challenges for SSR with React 18
  • Handling JS globals like document, window while working with SSR.

I think this post is of a reasonable length to skip those topics (else no one will read it 🚂), please go ahead and try these topics yourself and let me know in the comments/discussion if you would like another SSR post just covering these areas in depth.

Code for this post can be referenced here (final version)

--

--