Rendering React components/pages on the server-side is not a new topic, there are a lot of frameworks / libraries built specificly for this purpose. In fact, too many that it makes me very confused, what I want is a really simple way to just render the React page and also re-use the same routes. So, after looking into several popular solutions, I decided to just roll my custom setup with vitejs which is something I have been using at work for several months.

How does server-side rendering work?

Before we dive into this, I think it’s important to understand how exactly server-side rendering works. As the core of it, server-side rendering is a pretty simple concept, we serve the rendered HTML page to the client instead of just a placeholder HTML file and offload the “real” rendering logic to the browser.

So, there are 2 parts to this, first is that we need to be able to render the HTML page in the server using the same components that we use for the browser (nobody wants to write double the amount of code, doesn’t it?). The second part is to tell the browser to do its rendering logic on top of what we already have.

Let’s talk about fetching data later as it’s a more complicated topic.

Render your React components without a browser

Fortunately, React comes with a DOM server (react-dom/server), its whole purpose is to render the React components (in nodejs environment for example) into static HTML. There are many functions within react-dom/server to do this, but let’s go with renderToString since it’s the simplest one.

// src/entry.server.tsx
import ReactDOMServer from 'react-dom/server'
import { App } from './App'
import React from 'react'

export function render() {
  return ReactDOMServer.renderToString(<App />)
}

That’s it to render a React app into static markup

Hydrate your React components in the browser

Now that we have already rendered a static HTML page for our React components, we need a way to tell the browser that here is the already rendered markup, attach event listeners to it so that we can have an interactive page instead of a static page from the 90s.

// src/entry.client.tsx
import * as ReactDOM from 'react-dom/client';
import { App } from './App'
import React from 'react'

ReactDOM.hydrateRoot(<App />, document.getElementById('app'))

This is your typical entry point for a regular React application, but instead of using createRoot (before React 18, it was known as render), we now use hydrateRoot (before React 18, it was known as hydrate) to just attach the event listeners to the already rendered markup.

Serve the page

Now that we have 2 different ways to render the same React components, we still need to think about how we can deliver this to the end users. The process is simple, when the user requests a page (via an URL), we first render the static HTML page, and then load the React Javascript bundle on the browser for that same page.

And since it’s React, we need to “convert” it to pure Javascript because that’s what the browser understands. There are a lot of tools for this purpose, I’m choosing vitejs because it’s simple and fast. Then for the backend, I’m using the good old express but anything works. This is inspired by the official guide

import * as fs from 'fs'
import * as path from 'path'
import express from 'express'
import { Config, Environment } from './config'

const resolve = (p: string) => path.resolve(__dirname, p)

async function createServer() {
  const app = express()

  switch (Config.env) {
    case Environment.Development:
      // use vite's connect instance as middleware
      const vite = await require('vite').createServer({
        root: process.cwd(),
        logLevel: 'info',
        server: {
          middlewareMode: 'ssr',
          watch: {
            // During tests we edit the files too fast and sometimes chokidar
            // misses change events, so enforce polling for consistency
            usePolling: true,
            interval: 100,
          },
        },
      })
      app.use(vite.middlewares)
      app.use('*', async (req, res) => {
        try {
          const url = req.originalUrl

          // always read fresh template in dev
          const templateContent = fs.readFileSync(
            resolve('../index.html'),
            'utf-8',
          )
          // apply Vite's built-in HTML transforms
          // to get a proper template that we can serve
          const template = await vite.transformIndexHtml(url, templateContent)
          const render = (await vite.ssrLoadModule('/src/entry.server.tsx'))
            .render

          // this is how we render the React components into
          // static HTML
          const appHtml = render()

          // then we just "inject" the rendered content into the
          // template
          const html = template.replace(`<!--app-html-->`, appHtml)

          res.status(200).set({ 'Content-Type': 'text/html' }).end(html)
        } catch (e: any) {
          vite.ssrFixStacktrace(e)
          console.log(e.stack)
          res.status(500).end(e.stack)
        }
      })
    case Environment.Production:
      // For production, it's more of the same thing
      // but instead of serving the source, we now serve the built
      // version
      app.use(require('compression')())
      app.use(
        require('serve-static')(resolve('../dist/client'), {
          index: false,
        }),
      )
      const indexProd = fs.readFileSync(
        resolve('../dist/client/index.html'),
        'utf-8',
      )
      app.use('*', async (req, res) => {
        try {
          const url = req.originalUrl
          const render = require(resolve(
            '../dist/server/entry.server.js',
          )).render

          const appHtml = render()

          const html = indexProd.replace(`<!--app-html-->`, appHtml)

          res.status(200).set({ 'Content-Type': 'text/html' }).end(html)
        } catch (e: any) {
          console.log(e.stack)
          res.status(500).end(e.stack)
        }
      })
  }

  return app
}

createServer().then(app =>
  app.listen(3000, () => {
    console.log('http://localhost:3000')
  }),
)

For the index.html, it’s just a regular vite’s index page, nothing special here except the placeholder for the server-side rendered content.

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Vite App</title>
  </head>
  <body>
    <div id="app"><!--app-html--></div>
    <script type="module" src="/src/entry.client.tsx"></script>
  </body>
</html>

And that’s it, this is the most minimal setup that I can come up with and is flexible enough for all my needs (for now). Development experience is also pretty good, running vite in development automatically refreshes whenever there are new changes.

What else?

This is of course not complete without routes or data fetching. For routing, react-router has pretty good support for server-side rendering, there are separate component for each environment (StaticRouter and BrowserRouter)

// entry.server.tx
export function render(url) {
  return ReactDOMServer.renderToString(
    <StaticRouter location={url}>
      <App />
    </StaticRouter>,
  )
}
// entry.client.tx
ReactDOM.hydrate(
  <BrowserRouter>
    <App />
  </BrowserRouter>,
  document.getElementById('app'),
)

Data fetching is a whole different topic because there are many ways to do it, I will have a separate blog post in the future to discuss different options