Server Side Rendering In React

Matthew MacFarquhar
5 min readJul 22, 2024

--

Introduction

I have been reading this book on React 18 and its best practices. As I go through this book, I will distill the core pieces of the most interesting tid-bits in this series of articles on React. In this article we will be discussing server side rendering and what benefits it can bring to help us build a universal web application.

A universal (or isomorphic) web application is one where the same codebase can run on the client and server side, using this approach we will only have to write our code once to build a server-side rendered application.

Server-sider rendering brings two advantages

  1. Better SEO — By rendering some of the html on the server side instead of relying entirely on client run js to create the html, we are more friendly to web crawlers since many of them cannot run js and will therefore not have much to crawl on our page.
  2. *Slightly* Better Performance - by loading some parts of our app structure without the need to execute the js on the client side, we offer faster latency perceived by the client for slower devices — like mobile ones with 3G — which cannot load the bundle.js fast enough.

Creating a Universal App From Scratch

We will first go over the boilerplate to setup server-side rendering ourselves (usually a framework can handle this task).

Webpack Setup

We are building two apps here (one that runs on the server and one that runs on the client) so we will need two webpack configurations — luckily, we can put them in the same file.

const nodeExternals = require("webpack-node-externals");
const path = require("path");
const rules = [{
test: /\.(tsx|ts)$/,
use: 'ts-loader',
exclude: /node_modules/
}];

const client = {
mode: "production",
entry: "./src/client.tsx",
output: {
path: path.resolve(__dirname, './dist/public'),
filename: 'bundle.js',
publicPath: "/"
},
module: {
rules: rules
},
resolve: {
extensions: [".ts", ".tsx", ".js", ".json"]
}
};

const server = {
mode: "production",
entry: "./src/server.tsx",
output: {
path: path.resolve(__dirname, './dist'),
filename: 'server.js',
publicPath: "/"
},
module: {
rules: rules
},
target: "node",
externals: [nodeExternals()],
resolve: {
extensions: [".ts", ".tsx", ".js", ".json"]
}
};

module.exports = [client, server];

This will build a bundle.js which we will include in our html and will load up the code we create in our client.tsx file and put it in a bundle.js file. We will also create a server.js, which is what we will run using node and will serve a route to pre-render and return html.

Our package.json will looks like this

 "scripts": {
"build": "webpack",
"start": "node ./dist/server"
}

We will build our public/bundle.js and server.js using webpack and then run server.js with node.

The App

For the sake of simplicity, our actual web app will be very simple and just display a list of github issues.

import { FC } from "react"

type Gist = {
id: string,
description: string
}
type Props = {
gists: Gist[]
}

const App: FC<Props> = ({gists}) => (
<ul>
{gists.map((gist: Gist) => (
<li key={gist.id}>{gist.description}</li>
))}
</ul>
);

export default App

Our React App component expects a prop of gists which will be pulled from github (either by the client or the server). This component will be rendered by both the client.tsx (packed into bundle.js) and the server.tsx (served as an html from one of our server’s routes).

Server

Let’s take a look at template the server uses to create and pre-render the html that we will send.

export default (body: any, gists: any) => `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
</head>
<body>
<div id="root">${body}</div>
<script>window.gists = ${JSON.stringify(gists)}</script>
<script src="/bundle.js"></script>
</body>
</
`

We take in a String for the body (which will be the html contents of our App.tsx) and the gists data. We set window.gists to be the value of gists so that we don’t need to have our client code make another request to get the data and instead we can just load it from the window. We also include a reference to the bundle.js script so the client can load in the js it needs to function once the html markdown finishes rendering.

Now let’s see how our server creates this html to send to the client.

import express, {Request, Response} from "express";
import path from "path";
import { renderToString } from "react-dom/server";
import fetch from "isomorphic-fetch";
import App from "./app";
import template from "./template";

const app = express();
app.use(express.static(path.resolve(__dirname, "./dist/public")));


app.get('/', (_req: Request, res: Response) => {
fetch("https://api.github.com/users/gaearon/gists")
.then(response => response.json())
.then(gists => {
const body = renderToString(<App gists={gists}/>);
const html = template(body, gists);
res.send(html);
});
});

app.listen(3000, () => {
console.log('Listening on port 3000');
})

Our server runs on port 3000 and has a single endpoint at the root. When the root is called, we gather the gists data from github and also call a special renderToString function to create the DOM elements for our <App/> component. We pass in the body and our gists to our template to generate the html we will send to the client.

Client

At this point the browser on the client side has the markdown and the gist data needed to properly render static content. However, the client.tsx will be used to generate the bundle.js which contains important js logic which needs to be run client side (like functions and event handlers).

import App from './app';
import { createRoot } from 'react-dom/client';

type Gist = {
id: string
description: string
}

declare global {
interface Window {
gists: Gist[]
}
}


createRoot(document.getElementById('root') as HTMLElement).render(
<App gists={window.gists}/>
)

We grab the gists from the window since our server has nicely pre-loaded it for us, and then render our <App/> component in a very similar manner to how it is done on the server side.

Now we have an application that initially loads some needed data and html markdown server-side and defers client side js to the bundle.js. The server-side and client side React code are identical and allows us to manage both in a single code package.

Using Next.js

Now that we understand the nitty-gritty behind setting up our own server side rendered app, we will look at using Next.js instead — which will take care of much of this boilerplate js.

With Next.js, creating our application becomes as easy as creating an index page with an initialProps static function to populate the gists prop item.

import fetch from "isomorphic-fetch";
import React from "react";

const App = (props: { gists: any[]; }) => {
return (
<ul>
{props.gists.map((gist: any) => (
<li key={gist.id}>{gist.description}</li>
))}
</ul>
)
}

App.getInitialProps = async () => {
const response = await fetch("https://api.github.com/users/gaearon/gists");
const gists = await response.json();

return {
gists
}
}

export default App

and running next from our start script

"scripts": {
"dev": "next"
}

The complex pre-rendering and webpacking is taken care of by the Next.js framework. It is nice that this boilerplate has been abstracted away for us, but it is always good to understand what these frameworks are doing for us in case we want to preform more advanced actions that they do not support out of the box.

Conclusion

In this article we learned the process and step by step implementation of server-side rendering in react, we then delegated much of this boiler plate to Next.js and achieved the same results. We looked at the benefits of server-side rendering, namely better SEO and slightly better client perceived latency for initial page loads.

--

--

Matthew MacFarquhar
Matthew MacFarquhar

Written by Matthew MacFarquhar

I am a software engineer working for Amazon living in SF/NYC.

No responses yet