Server Side Rendering w/ React

AkashSDas
6 min readJun 13, 2024

--

Server side rendering is a rendering pattern that has being used since the pre SPA era. We can also implement this for React, and in this article we’ll go over its implementation, and pros/cons.

SSR generate the HTML page and sends it to the client as a response. The content may include data from our database or any other external source.

SSR process

The process works something like this:

  • The client requests a page.
  • Server process the request, runs the React component to generate HTML.
  • The fully rendered HTML page is sent to the client as a response.
  • Once the HTML is loaded, React takes over and hydrates the static HTML and makes it interactive by attaching event listeners and state.

The process occurs every time you make request. Even for the same page. This avoids the additional round trip that we’ve to make for data fetching. Since, we don’t need the rendering code, corresponding JavaScript isn’t sent to the client.

Yin Yang

SSR is a rendering pattern in React which we can implement by ourselves (like Netflix) or use frameworks like NextJS (pages router) or Remix. SSR isn’t a silver bullet. It has some great benefits but also introduces a few downsides.

Yin

By getting HTML page directly from the server we get great benefits like reduction in JavaScript as we’re not building the page which in turn increases the initial load and also gives quicker initial interactivity. This “quick interactive” part would be hampered if server has do a lot of work, as this will lead the end user to see empty screen. To know if you’re hampering user experience check if TTFB (time to first byte) metric.

Also, search engines can index the content of the pages more effectively since the content is already present in the HTML when the page loads.

Yang

Server has to render all of the React components and combined this with fetching data, a high traffic page would take time and hence giving user a bad experience.

This is similar to code splitting. It should used to make user experience better. We have check metrics and create a balance between initial load and interactivity.

You won’t have access to DOM things (like window or document) since we’re on server.

If you want to hand roll a SSR solution then would add a lot complexity to your project. This is where project like NextJS would shine. They would also provide caching strategies to mitigate a lot of load on the server.

Implementation

Either take a clone from the source code and run it or implement it along with way. Start by creating a React app with TypeScript using Vite.

pnpm create vite # react v18

# add pkgs
pnpm add express react-router-dom
pnpm -D add @types/express tsx

Create a ClientApp.tsx which will hold all of the client side related stuff like Router, Google Analytics, etc. This is the entry for client side app where our server response will be hydrated.

// src/ClientApp.tsx

import { hydrateRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App";

// All client related things should be here like Router, Google Analytics, etc.

hydrateRoot(
document.getElementById("root")!,
<BrowserRouter>
<App />
</BrowserRouter>
);

Change the entry point in index.html and use /src/ClientApp.tsx and while doing so, replace the root element where our content will populated by the client and is also the place where server will split content to send initial content and then perform server side rendering on the other part.

<!doctype html>
<html lang="en">

<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SSR</title>
</head>

<body>
<div id="root"><!--ssr-outlet--></div>

<script type="module" src="/src/ClientApp.tsx"></script>
</body>

</html>

Now, create ServerApp.tsx which will contain the server side JSX.

We can build ServerApp using Vite and import the built in to Node rather than having to built entire Node server instead we can use the ServerApp. This is because Node can’t read JSX. The alternate is using bable/node.

// Server.tsx

import {
RenderToPipeableStreamOptions,
renderToPipeableStream,
} from "react-dom/server";
import { StaticRouter } from "react-router-dom/server";
import App from "./App";

export default function render(
url: string,
otps: RenderToPipeableStreamOptions
) {
const stream = renderToPipeableStream(
<StaticRouter location={url}>
<App />
</StaticRouter>,
otps
);

return stream;
}

Update package.json:

{
...
"type": "module",
"scripts": {
"dev": "vite",
"build:client": "tsc && vite build --outDir ./dist/client",
"build:server": "tsc && vite build --outDir ./dist/server --ssr src/ServerApp.tsx",
"build": "pnpm build:client && pnpm build:server",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview",
"server": "tsx ./server.ts"
},
...
}

Create server.ts in the root your project:

// server.ts

import express from "express";
import path from "path";
import fs from "fs";
import { fileURLToPath } from "url";

import renderApp from "./dist/server/ServerApp.js";

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PORT = process.env.PORT ?? 8000;

const html = fs
.readFileSync(path.resolve(__dirname, "./dist/client/index.html"), "utf-8")
.toString();

const parts = html.split("<!--ssr-outlet-->");

const app = express();

// This will serve all of the static assets like imgs, JS, css
app.use(
"/assets",
express.static(path.resolve(__dirname, "./dist/client/assets"))
);

// Other things that aren't served by static assets will be served by React
app.use(function (req, res) {
// 1. return the header
res.write(parts[0]);

const stream = renderApp(req.url, {
onShellReady: () => {
// 2. return the body
// if it's the crawler, do noting (streaming makes it look slow to the SEO)
stream.pipe(res);
},
onShellError: (err: unknown) => {
// log error to logging service
console.error(err);
},
onAllReady: () => {
// if it's the crawler then dump everything to the client
// stream.pipe(res)

// sending the last part to the client
// and closing the request
res.write(parts[1]);
res.end();
},
onAllError: (err: unknown) => {
console.error(err);
},
});
});

app.listen(PORT, () => {
console.log(`Server is running on http://localhost:${PORT}`);
});

Here, we split JSX (<!--ssr-outlet-->) into 2 parts 1st will get the initial part (JS is moved to the top of the file which will suddenly load JS in the background) and in the background we’ll be running server side rendering which will give user a great experience).

Let’s create build and run the server:

pnpm run build:client
pnpm run build:server
pnpm run server
Result of SSR’s implementation

Now, Vite does have a much more standard way to implement SSR, and also there’s additional steps for having fetching data at server and passing it as a prop and compiling JSX to HTML with that data and sending it to the client. The implement we went on so far can achieve this with a few changes. All in all, this is how SSR works under the hood.

Conclusion

Whenever user makes a request for a web page, we don’t want send the bare HTML (index.html) where, one the index.html is loaded it then fetches App.tsx and style.css. Instead, they get the whole page and go through the content in the page. Hopefully by the time they decide to interact, the hydration process is done so that they can interact.

This reduces the time for first meaningful paint but will increase the time to interactivity. The idea is that you show user something and by the time they decide to do something, you would have loaded JavaScript in the background and would be able to do something.

Now, if the page has loaded but user isn’t able to click on something then that’ll be a bad experience. That’s why SSR isn’t a silver bullet and you have to look at different metrics to make sure that you’re making user experience better and not worse.

--

--