React SSR (Server-Side Rendering)

Image of Author
March 6, 2022 (last updated April 28, 2023)

SSR stands for server-side rendering (of javascript). It is a synecdoche for a workflow where the initial render of a web page is done on the server.

The acronym SSR can be misleading. It can cause a dev to think that all rendering is now server-side, but, in fact, only the initial render is server-side. After that, all rendering is handled by the client.

SSR improves the initial user experience.

Server rendering creates an illusion that the app loads faster by showing the HTML snapshot of its output.

SSR Workflow

The workflow to achieve SSR is as follows:

  1. Server-side: generate HTML from React via renderToString or via one of the renderTo*Stream APIs.
  2. Client-side: fetch the HTML and JS as per usual (e.g., HTTP response). The HTML payload generates the initial DOM and loads the client-side React.
  3. Client-side: Once the JS loads, call hydrateRoot to inform React about (1) the root DOM node and (2) the root React component. React will "hydrate" the root DOM node (aka reattach event listeners) and then take over subsequent renders.


When server-side React creates HTML it strips the JS out. Reintroducing the JS is conceptually straightforward: it is act of attaching event listeners to DOM nodes. (This is harder to do than it is to say.)

For example, let's consider a simple button component.

// const [x, setX] = useState(0)
<div>value: {x}</div>
<button onClick={() => setX(x + 1)}>Inc</button>

The React button.onClick prop tells React to create a button DOM element and attach the function () => setX(x + 1) as a listener callback to the click event. That JS gets stripped when you generate the HTML. For example, if you called renderToStaticMarkup, you'd get the following.

<div>value: 1</div><button>Increment</button>

This would render the button inert.

If you called renderToString or renderToPipeableStream , you'd get the following.

<div>value: <!-- -->1</div><button>Increment</button>

Once again, the button is inert. But, this time, React has given itself clues as to where the state is affecting the render via html comments, <!-- -->. I have played around with different outputs a bit, and, best I can tell, all React is doing here is adding html comments as markers for where state exists. One other observation is that the React docs are emphatic about having identical DOM trees on both client and server, as well as fixing any hydration mismatches that occur. These observations lead me to conclude that the html comments are the only thing React relies on to attach the right listeners to the right DOM nodes.

Streaming SSR

The streaming APIs are still relatively new, and so I do not trust my understanding of them. That said, the outputs of renderTo*Stream and renderToString seem to be the same for simple component trees. Thus, I don't think there is anything particularly magically about streams. The reason why the exist at all is because of the new React 18 Suspense APIs which allow you to suspend the render of component until certain conditions are met. Streams are a good way to send the initial render payload over time.

Server-side Static Markup Generation

Server-side React has other use cases beside SSR initial rendering. For example, you can use it to generate static markup. You can do this via renderToStaticMarkup, which will render "non-interactive" HTML. This allows you to use React as a templating engine.

For example, you could build email templates server-side.

import { renderToStaticMarkup } from "react-dom/server";
import EmailComponent from "./src/EmailComponent";

// the input is *server-side* props
// how you build them is contextual
// e.g., req auth + db read
function generateEmailHtml(props) {
  return renderToStaticMarkup(EmailComponent(props));