Build our own React SSR framework

2024-02-19

In this article, we are going to build our own SSR framework with React + Vite + Express.

You can think of it as a really simple version of Next.js 12 with the ability to SSR according to the file-routes system, performing client-side route change and user event after hydration.

Now let’s get started.

TL:DR

  • CSR vs. Traditional SSR
  • Introduce React SSR
  • Build our React SSR
  • SSR with routing
  • Wrap up πŸš€

CSR vs. Traditional SSR

Client Side Rendering

CSR refers to Client Side Rendering. If you are familiar with React, then you are familiar with CSR.

When a user enters a website, browser will get an empty HTML file with a script requesting for our React JS file.

1<!doctype html> 2<html lang="en"> 3 <head> 4 <!-- ... --> 5 </head> 6 <body> 7 <div id="root"><!-- we get empty div here --></div> 8 <script type="module" src="/src/main.tsx"></script> 9 </body> 10</html>

After loading and finish executing the React, React will then mount the entire App to the browser, at this point can the user finally see and interact with the website UI.

fig_1.png

As you may notice, the rendering of the page content can only starts after React takes over control, this can lead to some drawbacks:

  1. Users initially see only empty HTML while waiting React to load and execute, this lead to bad user experience
  2. Since empty HTML lacks the website information and body, it’s bad for the SEO.

Traditional Server Side Rendering

SSR refers to Server Side Rendering. Traditionally, SSR means that when the user enters a website, the server renders the HTML with all the content it needs and respond to the client. This is better for SEO and the β€œinitial” user experience.

Notice that I use β€œinitial”. For the later user interaction, such as route change within the same page, the user will always have to request a new HTML, this can lead to bad interaction experience later.

fig_2.png

Comparison on CSR and Traditional SSR

t_1.png

fig_3.png

In the next section, we will introduce React SSR, which combines the advantages of traditional SSR and CSR.

Introduce React SSR

So what do you mean by β€œReact SSR combines the advantages of traditional SSR and CSR”?

Well, the concept behind React SSR is actually a combination of Traditional SSR and CSR.

Concept of React SSR

When a user enters a website, the server will render the React App into HTML using a method called renderToString provided by React and sends the HTML back to the user. With this, user gets a contentful HTML initially.

And since the app is written with JSX, React can simply use the those JSX components on the client side too. This means, when the initial HTML is loaded on the browser, React β€œhydrates” the same components into the pre-rendered HTML, meaning it attaches client-side logic such as hooks and events onto the HTML, building a copy of vDOM, and taking control of the all subsequent user interactions.

fig_4.png

Comparison on CSR, Traditional SSR and React SSR

t_2.png

fig_5.png

Now we know the concepts, in the next section, we will start to build our simple React SSR framework with React, Vite and Express.

Build our React SSR

In this section, we will build our React SSR with Vite and Express.

Feel free to check the repo here πŸ‘ˆπŸ»

Project setup

Vite actually provides a CLI to setup all we need to build a React SSR.

1$ npm create vite-extra@latest 2# Project name: react_ssr_ssg_demo 3# Select a template: β€Ί ssr-react 4# Select a variant: β€Ί TypeScript

Then run:

1$ cd react_ssr_ssg_demo 2$ npm install 3$ npm run dev

Here is the current project structure:

1. 2β”œβ”€β”€ index.html # container HTML 3β”œβ”€β”€ package-lock.json 4β”œβ”€β”€ package.json 5β”œβ”€β”€ public 6β”‚ └── vite.svg 7β”œβ”€β”€ server.js # server for HTML requests 8β”œβ”€β”€ src 9β”‚ β”œβ”€β”€ App.css 10β”‚ β”œβ”€β”€ App.tsx # our react app 11β”‚ β”œβ”€β”€ assets 12β”‚ β”‚ └── react.svg 13β”‚ β”œβ”€β”€ entry-client.tsx # react for hydration 14β”‚ β”œβ”€β”€ entry-server.tsx # react for rendering react app into HTML on server 15β”‚ β”œβ”€β”€ index.css 16β”‚ └── vite-env.d.ts 17β”œβ”€β”€ tsconfig.json 18β”œβ”€β”€ tsconfig.node.json 19└── vite.config.ts

React SSR flow

When a user enters the website, our server will get a request for HTML, this request is handled by server.js .

server.js processes the request, get the url and giving it to entry-server.tsx, this is where React renders the React components according to the url into HTML string using a method called renderToString.

After server.js get the HTML string returned from entry-server.tsx, it puts the string into index.html and sends back to the browser. Now the user sees the initial rendered HTML.

Parsing the server-rendered HTML, browser will see the script:

1<script type="module" src="/src/entry-client.tsx"></script>

and requests for our client side react entry-client.tsx. After loading client side React, React will β€œhydrate” the entire vDOM into current HTML, attaching all hooks and events onto it, and taking control over the later user interaction.

fig_6.png

Let’s walk through each file.

server.js

First, create a server and listen to a port:

1// server.js 2 3import express from 'express' 4 5// Constants 6const port = process.env.PORT || 5173 7 8// Create http server 9const app = express() 10 11// Start http server 12app.listen(port, () => { 13 console.log(`Server started at http://localhost:${port}`) 14})

Second, Vite provides us Hot Module Reload functionality, which lets us refresh our project when saving files, however, we don’t need this function in production, thus, we will declare a flag to determine whether we are in production mode or not:

1// server.js 2 3// Constants 4const isProduction = process.env.NODE_ENV === 'production' 5 6// Add Vite or respective production middlewares 7let vite 8if (!isProduction) { 9 const { createServer } = await import('vite') 10 vite = await createServer({ 11 server: { middlewareMode: true }, 12 appType: 'custom', 13 base 14 }) 15 app.use(vite.middlewares) 16} else { 17 const compression = (await import('compression')).default 18 const sirv = (await import('sirv')).default 19 app.use(compression()) 20 app.use(base, sirv('./dist/client', { extensions: [] })) 21}

Third, handle the request from the browser, and server HTML back to browser. We will get the rendered HTML from entry-server.tsx here and put it into the index.html:

1// Cached production assets 2const templateHtml = isProduction 3 ? await fs.readFile('./dist/client/index.html', 'utf-8') 4 : '' 5const ssrManifest = isProduction 6 ? await fs.readFile('./dist/client/.vite/ssr-manifest.json', 'utf-8') 7 : undefined 8 9// Serve HTML 10app.use('*', async (req, res) => { 11 try { 12 const url = req.originalUrl.replace(base, '') 13 14 let template 15 let render 16 if (!isProduction) { 17 // Always read fresh template in development 18 template = await fs.readFile('./index.html', 'utf-8') 19 template = await vite.transformIndexHtml(url, template) 20 render = (await vite.ssrLoadModule('/src/entry-server.tsx')).render 21 } else { 22 template = templateHtml 23 render = (await import('./dist/server/entry-server.js')).render 24 } 25 26 const rendered = await render(url, ssrManifest) 27 28 const html = template 29 .replace(`<!--app-head-->`, rendered.head ?? '') 30 .replace(`<!--app-html-->`, rendered.html ?? '') 31 32 res.status(200).set({ 'Content-Type': 'text/html' }).send(html) 33 } catch (e) { 34 vite?.ssrFixStacktrace(e) 35 console.log(e.stack) 36 res.status(500).end(e.stack) 37 } 38})

index.html

For index.html, the content will be replaced by the rendered HTML by server.js , and being send back to the browser. Noted that it will request entry-client.tsx once being parsed on the browser:

1<!--index.html--> 2 3<!DOCTYPE html> 4<html lang="en"> 5 <head> 6 <meta charset="UTF-8" /> 7 <link rel="icon" type="image/svg+xml" href="/vite.svg" /> 8 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 9 <title>Vite + React + TS</title> 10 <!--app-head--> 11 </head> 12 <body> 13 <div id="root"><!--app-html--></div> 14 <script type="module" src="/src/entry-client.tsx"></script> 15 </body> 16</html>

entry-server.tsx

For entry-server.tsx, what it does for now is to render the React App (JSX) into HTML string using a method provided by react-dom/server called renderToString :

1// entry-server.tsx 2 3import React from 'react' 4import ReactDOMServer from 'react-dom/server' 5import App from './App' 6 7export function render() { 8 const html = ReactDOMServer.renderToString( 9 <React.StrictMode> 10 <App /> 11 </React.StrictMode> 12 ) 13 return { html } 14}

entry-client.tsx

For entry-client.tsx, what it will do is to build a vDOM by the same React App pre-rendered on server and β€œhydrates” all client side hooks and events into the HTML on the client side.

One important thing to be noted is that we are using hydrateRoot method instead of createRoot , with hydrateRoot , we are not switching the HTML with our client React rendered App, instead we attach the client side logic into the HTML.

1import './index.css' 2import React from 'react' 3import ReactDOM from 'react-dom/client' 4import App from './App' 5 6ReactDOM.hydrateRoot( 7 document.getElementById('root') as HTMLElement, 8 <React.StrictMode> 9 <App /> 10 </React.StrictMode> 11)

Server-rendered HTML must have a same DOM tree as the client React DOM tree, or else React will throw a β€œhydration error” to you in the console.

For example, if the server renders:

1const html = ReactDOMServer.renderToString( 2 <React.StrictMode> 3 <div>hihi</div> 4 </React.StrictMode> 5);

While client React renders:

1ReactDOM.hydrateRoot( 2 document.getElementById("root") as HTMLElement, 3 <React.StrictMode> 4 <App /> 5 </React.StrictMode> 6);

You will get a hydration error like this:

fig_7.png

SSR with routing

For now, our App still lack of routing system, let’s add one to it.

We will mimic the Nextjs file routing system using React Router DOM.

1$ npm i react-router-dom

Then create /pages folder inside /src , add some page components to it:

1. 2β”œβ”€β”€ src 3β”‚ β”‚ 4β”‚ └── App.tsx 5β”‚ β”œβ”€β”€ pages 6β”‚ β”‚ β”œβ”€β”€ About.tsx 7β”‚ β”‚ └── Home.tsx
1// src/pages/Home.tsx 2 3import { useState } from "react"; 4import { Link } from "react-router-dom"; 5 6const Home = () => { 7 const [count, setCount] = useState(0); 8 9 const onClick = () => { 10 setCount(count + 1) 11 } 12 13 return ( 14 // <div> 15 // this is home page 16 // <button 17 // type={"button"} 18 // onClick={onClick} 19 // > 20 // add 21 // </button> 22 // <Link to="/about">to about page</Link> 23 // </div> 24 ); 25}; 26 27export default Home;
1// src/pages/About.tsx 2 3import { Link } from "react-router-dom"; 4 5const About = () => { 6 return ( 7 // <div> 8 // this is about page 9 // <Link to={"/"}>to home page</Link> 10 // </div> 11 ); 12}; 13 14export default About;

For App.tsx, erase all default content and add some routing logic to it:

1// src/App.tsx 2 3import { Route, Routes } from "react-router-dom"; 4 5// Vite supports importing multiple modules 6// from the file system via the special import.meta.glob function 7const PagePathsWithComponents = import.meta.glob("./pages/*.tsx", { 8 eager: true, 9}) as Record<string, Record<any, any>>; 10 11const routes = Object.keys(PagePathsWithComponents).map((path: string) => { 12 const name = path.match(/\.\/pages\/(.*)\.tsx$/)![1]; 13 14 return { 15 name, 16 path: name === "Home" ? "/" : `/${name.toLowerCase()}`, 17 component: PagePathsWithComponents[path].default, 18 }; 19}); 20 21export default function App() { 22 return ( 23 <Routes> 24 {routes.map(({ path, component: Component }) => { 25 return <Route key={path} path={path} element={<Component />} />; 26 })} 27 </Routes> 28 ); 29}

It imports all files with name .tsx as extension, and renders the component with matched route:

1<Routes> 2 {routes.map(({ path, component: Component }) => { 3 return <Route key={path} path={path} element={<Component />} />; 4 })} 5</Routes>

In entry-client.tsx , wrap the <App /> with BrowserRouter provider just like what we will do in normal CSR react app, this allows user to perform client-side route change when going to another page through <Link> provided by React Router DOM:

1// entry-client.tsx 2 3import { BrowserRouter } from "react-router-dom"; 4 5// ... 6ReactDOM.hydrateRoot( 7 document.getElementById("root") as HTMLElement, 8 <React.StrictMode> 9 <BrowserRouter> 10 <App /> 11 </BrowserRouter> 12 </React.StrictMode> 13);

In entry-server.tsx , however, we will use StaticRouter , this is because we will not memorize the route change history when user hit the route directly in the url bar since we don’t have History API like browserRouter does in the browser, we will render whole new HTML that matches the current url:

1// entry-server.tsx 2 3import { StaticRouter } from "react-router-dom/server"; 4 5// ... 6const html = ReactDOMServer.renderToString( 7 <React.StrictMode> 8 <StaticRouter location={"/" + url}> 9 <App /> 10 </StaticRouter> 11 </React.StrictMode> 12);

Now when user visit certain page through refresh or typing inside url bar, we will serve them whole new HTML, while navigating through <Link> button on browser, we will navigate to new page through client side routing:

fig_8.gif

Wrap up πŸš€

That’s all for this article, we’ve learned what React SSR solves and how to build our own React SSR with file-routing system.

We still have a lot of functionalities to be built, such as Static Site Generation and pre-fetching data before rendering HTML. We will leave them to the future articles.

That’s it, thank you for reading, hope you enjoy it πŸš€.

Reference