aeroheim-logoblog
projects
about

back to blog

Server Side Rendering With React: An Implementation

MAR 26, 2019a personal case study plus steps for implementing SSR
    dev

Server Side Rendering With React: An Implementation


Server-side rendering is often treated with an air of mysticism as some kind of advanced feature (rightfully so in my opinion) - it offers numerous advantages such as potentially faster response times (server render speed vs. bundle size) and SEO-friendliness (I’m looking at all you crawlers out there that still can’t render a SPA in 2019 💢), but is also quite difficult to implement due to it’s cross-cutting nature that combines many aspects of both the client and the server. Having recently worked on a mostly from scratch server-side rendering implementation for a SPA, I’ve decided to write down my overall process for implementing it as well as any gotchas, lessons learned, and interesting tidbits of information that I discovered during the implementation.

Introduction


For my case in particular I developed a client-only SPA for sometime before realizing that I actually needed to be able to support rendering on the server as well. Just imagine how much fun it is to have to re-visit your entire application for any client-only architectural decisions or assumptions you made! Regardless of rationale for implementing server-side rendering (which I’ll shorten to SSR for the rest of the post) however, I’ve tried to boil down the entire process into a series of steps as organized below:

Keep in mind that this post was mainly based off of an implementation of SSR for my particular use-case. In particular, this post assumes that you’re using the following stack:

There are also some caveats that haven’t really been explored yet with this implementation (e.g code splitting) - please check out Caveats And Additional Info for more info. With that in mind, let’s get started!

Getting The App Ready For SSR


As I mentioned before, implementing SSR involves aspects of both the client and the server. In terms of the app, the general requirements for implementation are as follows:

  • The app must be fully renderable from the server on initial request.
  • The app must be able to take over control of all future rendering once it fully loads on the client’s browser.
  • The process must be seamless such that no additional re-rendering is performed when the app fully loads.

I’ll go into greater detail on how to achieve these requirements below.

Dependency Injection For State Management And Routing

In a typical SPA the histories for routing and stores for state management are often initialized in the main app entry point (e.g index.jsx) where the root component renders. Below is simple example of this pattern:

// index.jsx
import React from 'react';
import { createStore } from 'redux';
import { Provider } from 'react-redux';
import { Router } from 'react-router-dom';
import createHistory from 'history/createBrowserHistory';

const store = createStore({ /* your application state */ });
const history = createHistory();

ReactDOM.render(
  <Provider store={store}>
    <Router history={history}>
      <YourApp />
    </Router>
  </Provider>,
  document.getElementById('root'),
);

This is sufficient for a client-only application, but to support SSR these dependencies must be made injectable. The reasoning is as follows:

  • For routing the browser’s history API (i.e window.history) isn’t available on the server so most router implementations provide an alternative that should be used instead.
  • For state management it’s important to be able to instantiate your stores directly in the server and inject them into the app since you’ll need to extract and serialize the state of your stores for Preloading State From The Server later on.

Below is an example of how the above component can be refactored to accept the stores and routers as dependencies:

// root-component.js
export const Root = ({ store, history }) => {
  return (
    <Provider store={store}>
      <Router history={history}>
        <YourApp />
      </Router>
    </Provider>
  );
};

If your root component is exported by your main app entry point (e.g index.jsx), you’ll need to move it to another module. The main app entry point should continue to initialize its history and stores just as before except it now imports the root component from another module like below:

// index.jsx
import Root from './root-component';

const store = createStore({ /* your application state */ });
const history = createHistory();

ReactDOM.render(
  <Root store={store} history={history}/>,
  document.getElementById('root'),
);

The reason for doing this is so that client can still continue to initialize its dependencies when it loads and executes. From the server’s perspective it is now able to import the root component for rendering from a different module without referencing the main entry point module and causing it to execute (you definitely don’t want to be calling ReactDOM.render on the server after all).

Preloading State From The Server

One of the trickier things to understand in SSR is how the client will take control over the initial server rendered content when it finally loads. The process for SSR goes through the following steps:

  1. Initial browser request
  2. Server renders the app
  3. Server sends rendered html as response (you’ll see your site by this point in time)
  4. App javascript bundle finally loads and executes (this will often happen later due to typical bundle sizes)

This section concerns #4, which is making sure that the client has the correct state when it loads. For example, an app may decide to store some state in redux depending on the specific route that was rendered (e.g storing a blog post’s content in redux for /blog/:id).

We’ll make a small addition to the main entry point example code that we used in the previous section to handle this:

// index.jsx
import React from 'react';
import { createStore } from 'redux';
import createHistory from 'history/createBrowserHistory';
import Root from './root-component';

let initialState = null;
if (!global.__SERVER__) {
  // load state from server-side rendered app
  initialState = window.__INITIAL_STATE__;
  delete window.__INITIAL_STATE__;
}

const store = createStore({ /* your application state */ }, initialState);
const history = createHistory();

ReactDOM.hydrate(
  <Root store={store} history={history} />,
  document.getElementById('root'),
);

This new code may seem somewhat confusing at first, but it basically boils down to the following points:

  • We’ll add a new global variable called __SERVER__. This variable will be set only when the server renders the app, which gives us a way to tell when the app is being rendered on the server vs. the client.
  • The server rendered html will contain inlined javascript (example shown later in Serializing The State) that sets a variable on the window called window.__INITIAL_STATE__. This variable is the serialized state of the app as it was rendered on the server.
  • When we’re rendering on the client after the app fully loads, the app will read window.__INITIAL_STATE__ and use that as the initial state for the redux store it creates. This allows the application’s stores to resume the exact same state that the application was at when it was rendered on the server.
  • Finally, we should switch to using ReactDOM.hydrate instead of ReactDOM.render. This is because the app has already been rendered into html by the server so there’s no need to render it again when the app loads. Per the documentation of hydrate, this will tell React to only attach event listeners to the existing markup and do no further rendering.

Toggling DOM-specific Logic

Because the app now needs to be renderable on the server, we need to make sure that any logic that depends on browser-only objects such as window and document do not execute on the server. This can be done by either by using the __SERVER__ global variable that we introduced in the previous section or checking for undefined as shown below:

// an example of DOM-specific logic
if (typeof window !== 'undefined') {
  document.getElementById('app').scrollTo(0, 0);
}

In addition, this is also a good point in time to make sure that any DOM-specific logic that you perform during component initialization is moved into the componentDidMount lifecycle method. React does not call componentDidMount on the server which will ensure that you won’t accidentally call DOM-specific logic when your components are being initialized as they’re rendered on the server. If you have any animation components like in my case, this is also a good time to refactor them so that they effectively no-op and still allow for the correct html to be rendered.

Handling Requests & Responses Pt. 1 - App

When it comes to implementing SSR, handling requests and responses is also another difficult problem that can be rather tricky to handle. Because requests and responses are asynchronous in nature, it’s not possible for a server to completely render an application in a single pass if it contains components that conditionally render based on responses (e.g a <Blog> component whose rendered content depends on a response).

The full solution will be detailed later in Handling Requests & Response Pt. 2 - Server, but for now what’s important is that the app provides a way to track all of its requests promises. To meet these requirements, we’ll first create a new redux store that tracks all requests promises:

// requests-reducer.js

const QUEUE_REQUEST = 'QUEUE_REQUEST';
const queueRequest = request => ({
  type: QUEUE_SSR_REQUEST,
  request,
});

const initialState = {
  requests: [],
};

const RequestsReducer = (state = initialState, action) => {
  switch (action.type) {
    case QUEUE_REQUEST:
      return { ...state, requests: [...state.requests, action.request] };
    default:
      return state;
  }
};

export {
  QUEUE_REQUEST,
  queueRequest,
  RequestsReducer,
}

We’ll then have all requests promises made in the app be tracked by this store. One important thing to note here is that you’ll have to modify the url of your requests when rendering on the server as it will be different (e.g localhost instead of your actual URL). In this example here we do a simple check against global.__SERVER__ and then use a global.__SERVER_URL__ that is set to ‘localhost’ to configure our requests.

import Axios from 'axios'; // this example uses axios for requests/responses. you may substitute it with your library of choice
import { queueRequest } from './requests-reducer';

const requestConfig = global.__SERVER__
  ? { baseURL: global.__SERVER_URL__ }
  : {};

// make the request
const request = Axios.get(`/api/someResource`, requestConfig)

// track the request in the store
if (global.__SERVER__) {
  dispatch(queueRequest(request)); // this uses redux's 'dispatch' to perform an action
}

With these changes in the app in place we’ve laid the groundwork necessary for the server to correctly handle requests and responses when rendering the app. Keep in mind that the current implementation so far is incomplete and is only one part of the full solution - please continue reading further to Handling Requests & Response Pt. 2 - Server to understand how the full solution works and how this fits into it.

Getting The Server Ready For SSR


At this point we’ve made all the changes we need to the app to facilitate SSR. For the server, there’s another list of general requirements for implementation:

  • The server must be able to render the app.
  • The server’s rendered html of the app should be identical (or nearly identical) to the client rendered output.
  • The server’s rendered app state should be identical (or nearly identical) to the client rendered output’s state.

Transpiling The Server

Depending on the implementation of your server you may or may not have already had the need to transpile and bundle your backend (e.g if you use Typescript or ES6+ features). Regardless however, you’ll need to make sure that you include all the additional transpilation steps that you normally do for your app as part of your server transpilation process as the server now needs to be able to load your app’s modules in order to render it.

For illustration, given the case of a regular non-transpiled node backend that serve an app transpiled using webpack:

// webpack.config.js - the configuration shown here is a generic example for illustrative purposes.

const appConfig = {
  name: 'app',
  entry: './src/app.jsx',
  output: {
    filename: 'app.js',
    path: path.resolve(__dirname, 'dist'),
    publicPath: '/',
  },
  resolve: {
    extensions: ['.js', '.jsx'],
  },
  module: {
    // your loaders (e.g babel-loader, css-loader, etc)
  },
};

You’ll want to either add a new server webpack configuration or extend your existing one along the lines of what’s shown below:

// webpack.config.js - the configuration shown here is a generic example for illustrative purposes.

const serverConfig = {
  name: 'server',
  entry: './src/server.jsx',
  output: {
    filename: 'server.js',
    path: path.resolve(__dirname, 'dist'),
    publicPath: '/',
  },
  resolve: {
    extensions: ['.js', '.jsx'],
  },
  target: 'node',
  module: {
    // your loaders for server, including the loaders from your app (e.g babel-loader, css-loader, etc).
  },
};

Because this can potentially be a large change in your backend that touches many files (especially if your backend wasn’t transpiled before), you may want to spend some time carefully refactoring existing server code with some of the following items in mind:

  • Take advantage of new ES features introduced by the transpilation.
  • Re-evaluate usage of modules in your server (e.g import vs require).
  • Revisit assumptions made with paths such as __dirname.

Collecting Critical CSS

When a browser loads a page it waits for all stylesheets resources to fully load before rendering content. Critical CSS is a subset of your stylesheets that represents only the absolute minimum CSS that is actually needed for rendering the page requested. In terms of SSR this is important because the server should only be concerned with inlining the relevant CSS for a rendered page instead of sending the entirety of your app’s stylesheets.

Since there are many different ways styling can be handled in React (e.g css-modules, styled-components, JSS) each with their own varying levels of support for critical CSS, this section won’t cover implementing it. What’s important however is that the critical CSS is accessible in some kind of format so that it can inlined within the server rendered html that we’ll discuss about in the next section.

Rendering The App

In this section I’ll talk about the actual process of rendering the app on the server. This is by far the most important and crucial part of implementing SSR (as evident by the name), and involves putting together the implementations of the different topics discussed so far into action.

On a typical backend that doesn’t do any rendering on the server, the app is referenced as a script in index.html like below:

<html lang="en">
  <head>
    <meta charset="utf-8">
  </head>
  <body>
    <div id="root" class="root"></div>
  </body>
  <script src="/app.js"></script>
</html>

And the server will typically serve index.html in the following fashion:

import express from 'express';
import path from 'path';

function listen(port) {
  // server.js
  const app = express();

  // serve static files
  app.use('/', express.static(path.join(__dirname, 'dist')));

  // your api
  app.use('/api', yourApiRouter);

  // serve index.html - all unhandled requests go here
  app.get('*', (req, res) => res.sendFile(path.join(__dirname, 'index.html'))); 

  app.listen(port);
}

This example represents a typical (and greatly simplified) server setup that contains an API, serves static files, and serves the app (in the form of index.html).

For SSR the idea is to have the server directly render the resulting html that a client would expect to see as if the app was running on their browser the entire time. To do this, we’ll modify the above example a bit so that the server now directly controls the html that it’s sending:

// render sends an html string
function render(req, res) {
  res.send(`
    <!doctype html>
    <html lang="en">
      <head>
        <meta charset="utf-8">
      </head>
      <body>
        <div id="root" class="root"></div>
      </body>
      <script src="/app.js"></script>
    </html>
  `);
}

function listen(port) {
  // etc...
  app.use(render); // we now have render handle all unhandled requests instead of serving index.html
  app.listen(port);
}

Now that we’ve setup the server to be able to directly control the html being rendered, it’s time to start rendering the app itself on the server. Our goal is to be able to render the app into a resulting html string and then directly interpolate it into our existing html string that we send from render. Since we’ve setup the app with dependency injection in mind earlier (see Dependency Injection For State Management And Routing), we can instantiate all of the app’s necessary dependencies on the server and then pass them into the app for rendering:

import React from 'react';
import { renderToString } from 'react-dom/server';
import { StaticRouter } from 'react-router-dom';

const initialStyles = /* your solution for critical CSS */;

const AppForServer = ({ store, location }) => (
  <Provider store={store}>
    <StaticRouter location={location} context={{}}>
      <YourApp />
    </StaticRouter>
  </Provider>
);

function render(req, res) {
  const store = createStore({ /* your application state */ });
  let renderedApp = renderToString(<AppForServer store={store} location={req.url}/>);

  res.send(`
    <!doctype html>
    <html lang="en">
      <head>
        <meta charset="utf-8">
        ${initialStyles}
      </head>
      <body>
        <div id="root" class="root">${renderedApp}</div>
      </body>
      <script src="/app.js"></script>
    </html>
  `);
}

There are several things to note here:

  • As mentioned in Collecting Critical CSS, we inline the critical CSS for our app (shown here as initialStyles) as part of the server rendered html.
  • We’re using React’s renderToString function here which renders the app into an html string, which we then interpolate into the existing html string that sent by render.
  • We’re also using react-router’s <StaticRouter> which is used for SSR. We pass the location of the request URL to the router and have it render the client-side route with it.
  • We instantiate a store on the server and inject that into the app. This will become more important later on.

With the current example we’ve got basic rendering of the app implemented on the server. However there are still a few important items missing from the implementation that prevents the rendered app from being rendered correctly, which I’ll cover in the following sections.

Handling Requests & Responses Pt. 2 - Server

To recap what I discussed in Handling Requests & Responses Pt. 1 - App, we implemented a mechanism for the app to track its pending request promises in its store. This mechanism is crucial to allowing the server to correctly send responses for all of the app’s requests while it’s rendering, as I’ll now show below:

async function render(req, res) {
  const store = createStore({ /* your application state, including the requests-reducer we created earlier */ });
  let renderedApp = renderToString(<AppForServer store={store} location={req.url}/>);
  // wait for pending requests to finish.
  await Promise.all(store.getState().requests.requests);
  // render again after resolving all requests.
  renderedApp = renderToString(<AppForServer store={store} location={req.url}/>);

  res.send(`
    <!doctype html>
    <html lang="en">
      <head>
        <meta charset="utf-8">
        ${initialStyles}
      </head>
      <body>
        <div id="root" class="root">${renderedApp}</div>
      </body>
      <script src="/app.js"></script>
    </html>
  `);
}

The key changes made here are the following:

  • We get the list of pending request promises from the requests-reducer implemented earlier using store.getState().requests.requests.
  • We use Promise.all() on the list of pending request promises to get a single Promise object that resolves when all requests have finished.
  • We use async/await to wait until the Promise.all() has resolved. Alternatively you can also use the regular promise syntax (e.g Promise.all().then()) instead.
  • When the pending requests resolve, they update the redux store’s state by dispatching additional actions. As a result, after all promises have resolved the store object should now be updated with the correct state. We render the app again with this new state which will then produce our final desired html.

The pattern described above should handle basic cases where relatively straightforward requests are involved. If your app has a more complicated use case such as sending series of requests based on responses, you can easily extend this pattern in the following fashion:

// clearRequests is an action that clears all requests in request-reducer
import { clearRequests } from './requests-reducer';

async function render(req, res) {
  const store = createStore({ /* your application state, including the requests-reducer we created earlier */ });
  let renderedApp = renderToString(<AppForServer store={store} location={req.url}/>);

  // continue re-rendering the app until all requests have been resolved.
  while (store.getState().requests.requests.length !== 0) {
    // wait for pending requests to finish.
    await Promise.all(store.getState().requests.requests);
    // clear all pending requests now that they've resolved.
    store.dispatch(clearRequests());
    // render again after resolving all requests - this may generate additional new requests.
    renderedApp = renderToString(<AppForServer store={store} location={req.url}/>);
  }

  // etc...
}

This extension continues to re-render the app until all pending request promises have been resolved, which should handle cases where multiple series of requests and responses are involved.

Serializing The State

At this point we’ve implemented proper rendering of the app on the server which should be correct. The resulting html should be no different (with a few exceptions) than the html generated as if the app were running on the browser itself.

One remaining issue however is making sure that the app correctly resumes from the server rendered app’s state once it finally loads on the client’s browser. We’ve implemented the necessary architecture on the app to support resuming from a pre-loaded state in Preloading State From The Server, so all that’s left now is to actually send that state to the app as part of the server rendered html.

Fortunately enough for us this step is relatively simple:

import serialize from 'serialize-javascript';

async function render(req, res) {
  const store = createStore({ /* your application state, including the requests-reducer we created earlier */ });

  // etc...

  res.send(`
    <!doctype html>
    <html lang="en">
    <head>
      <meta charset="utf-8">
      ${initialStyles}
    </head>
    <body>
      <div id="root" class="root">${renderedApp}</div>
    </body>
      <script>
          window.__INITIAL_STATE__ = ${serialize(store.getState())}
      </script>
      <script src="/app.js"></script>
    </html>
  `);
}

I’m using the serialize-javascript library here to serialize my server rendered app’s store state, which I then assign into window.__INITIAL_STATE__ as part of my server rendered html. Once the app loads on the client’s browser, it’ll deserialize this state and use it as the initial pre-loaded state for its redux stores.

One thing to note when implementing serialization of the state however is to make sure that your redux state is actually serializable. This means that if you use complex objects or object references as part of your state (e.g Map, Set) you’ll need to either find a way to properly serialize them or use alternatives instead that are serializable.

Putting It All Together - The Workflow Explained


Now that I’ve covered the entire process of implementing SSR, I’ll step back a bit and try to organize everything that I’ve talked about into a workflow, starting from the beginning where the server receives a request and ending at when the app fully loads on the client’s browser. This workflow will serve as a summary of everything discussed so far and include all of the SSR related steps that I’ve talked about in this post.

  1. Client’s browser makes a request to the server.
  2. Server receives the request and renders the app (pre-requisite: Transpiling The Server)
    1. Server collects critical CSS for the app (Collecting Critical CSS)
    2. Server initializes its own instances of stores/routers for use when rendering the app (Dependency Injection For State Management And Routing)
    3. Server does an initial render of the app (Toggling DOM-specific Logic, Rendering The App)
    4. Server waits for pending requests and re-renders until no more requests are unresolved (Handling Requests & Responses Pt. 1 - App, Handling Requests & Responses Pt. 2 - Server)
    5. Server serializes the state of the server rendered app (Serializing The State)
    6. Server sends an html string as a response, including inlined critical CSS, html of the server rendered app, and serialized state of the server rendered app.
  3. Client’s browser receives the server rendered html as a response and can now “use” the app. Any route changes results in another SSR response from the server.
  4. App is downloaded and begins to load on the client’s browser.
    1. App uses the pre-loaded state sent from the server as the initial state (Preloading State From The Server)
    2. App hydrates the existing server rendered html instead of doing a full re-render. (Preloading State From The Server)
  5. App is now fully loaded and available for use on the client’s browser. Any route changes are now handled by the app’s client-side routing instead of additional SSR requests.

Conclusion


Implementing SSR is definitely not a trivial task and involves a solid understanding of both the client and the server. Although I’ve presented an implementation of SSR with details of all the steps involved in this post, my implementation went through many iterations before I ended up with a solution that I was fairly content with. Also not detailed here was the considerable amount of time I spent trying to understand all the different requirements needed for SSR and what a feasible solution would need to provide 💦. It’s my hope that the material that I’ve discussed in this post will be helpful or insightful in some way to anybody looking to roll their own implementation of SSR, or even to those who are curious on what an implementation of it would look like.

As always, thanks for reading!

Caveats And Additional Info


  1. The implementation of SSR discussed in this post does not include any explicit support for code splitting. Code splitting and SSR has a reputation for being even more complex to implement, but there are additional resources and frameworks out there that can shed some light on it.
  2. Frameworks such as next.js provide SSR support out of the box. They’re a viable alternative if you’re looking for SSR for free without much work.