Let’s build a React from scratch: Part 4— Server Side Rendering and its Challenges
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.
- Part 1 — VirtualDOM and React renderer
- Part 2 — State Management and React Hooks
- Part 3 — React Suspense and Concurrent Mode
- Part 4 — Server Side Rendering and its Challenges
🫐 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.
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 -
- 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.
- 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.
🥞 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
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.
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.
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
).
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,
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,
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 name
as 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 ofreact-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)
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 thebuild
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.
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.
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
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
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
What we see is it generates the random className
But, 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.
🧸 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.