blog.jakoblind.no

Case study of SSR with React in a large e-commerce app

Setting up SSR (server side rendering) with React is painful. There is no good overview or starting point. Instead, you have to gather pieces of information from googling and do your best to puzzle them together.

We have been through this process in the project I am currently working on. Today we have a fully working SSR solution that has been in production for almost three years.

In this case study you will learn:

  • Concrete tips for handling issues that can occur while implementing SSR
  • The approach we used to go from knowing nothing about SSR to have it implemented in production
  • The thought process behind our decision making

Introduction

In this case study, I present how we have implemented SSR (server side rendering) with React in an e-commerce application for one of Norway’s largest telco brands Telia. The app has been developed over nearly 3 years and we are 6 developers on the team.

It took us many hours to create a robust and maintainable SSR solution. Learning, experimenting and implementing all takes time. By sharing our thought process and concrete implementation details I want to help you reduce your time and effort to a successful SSR implementation.

This is an honest case study. I share decisions we made that we later changed. In software development, you rarely make the “correct” architecture on the first try, but you try things out and constantly improve your code base.

One key take-away from this report is to keep things simple. What we have learned over the 3 years we have worked with the app is that keeping things simple helps a lot when implementing and maintaining an SSR solution.

If you want an introduction to SSR I suggest my post Getting started with Universal rendering

The stack

I only list the most relevant technology for understanding implementation of SSR.

  • On the backend, we are using node and express.
  • On the frontend, we are using React and Redux.
  • We use webpack to build and we run the app on AWS

The site

The app is the webshop for one of the largest telcos in Norway: Telia. You can check it out at nettbutikk.telia.no

In the webshop you can buy hardware such as phones, tablets and accessories. The main product is subscription which can be bought standalone or bundled with hardware.

History of React and SSR in our project

Let’s start by looking into how we implemented SSR back in 2015 when we started the project. It can be interesting to understand what mistakes we made, and how we improved it. If you just looking for how our solution looks today, you can skip this section.

When we started this project we were three experienced programmers who got the task to re-implement an existing webshop and we picked Node.js/React for the job. Neither of us had any previous experience of React except the tutorial and some simple experimentation.

Picking Reflux (Redux didn’t exist)

At this time many developers implemented Flux from scratch and Redux didn’t exist. We found a library called Reflux which was an implementation of Flux. We thought it seemed like a good idea to not reimplement the wheel, so we picked that one.

One problem with Reflux related to SSR is that the stores are singletons and was not made to run on the server. So we had some nasty bugs where parts of the state got shared across users.

When we discovered Redux, we quickly got curious and wanted to start using it. Today we have migrated most of the code base, and we are right now working on removing the last parts of Reflux!!

Require the frontend bundles from the backend

To be able to access the frontend code from the backend, we did a require (we didn’t use ES6 in the beginning) of the generated frontend bundle. That was a workaround to avoid having to transpile the backend code to be able to parse JSX.

The problem with that solution was that we had to generate the client bundle on every code change while developing. And that is slow. Our quick fix was to disable SSR while working in dev mode. And you maybe can guess what that led to? When we accidentally broke SSR, we didn’t notice because it didn’t break in dev mode. That lead to many deploys with broken SSR to production.

We have moved away from using the generated client bundle in the backend. Instead, we run the whole backend through babel which means we can run SSR in dev mode without any problem. Also, it has become a lot more easy to reason about the SSR than when we included the bundle. You will read more about how we do transpiling of JSX later.

Today

The theory of SSR is pretty simple: you just render your React components on the backend and send the generated HTML to the client. In practice, there’s a lot more to think about than that.

Let’s go through all areas of the code base that are impacted by SSR.

Transpiling of the backend code

To be able to do server-side rendering, the backend must understand JSX just like the frontend does. The way we achieve that is to run the backend code through babel just like we do with the frontend code.

To do that you can either use webpack, or you can use babel only. We started with an implementation using webpack, but then we changed to using babel only because it became a lot more simpler and we realized we didn’t need webpack for anything on the backend… except babel.

We run babel like this for our production build:

babel . --out-dir ../dist/ --source-maps --copy-files

When working locally in dev mode we want the server to automatically restart when we do code changes to speed up development. The way we do that is that we have a file server.babel.js that we start instead of our regular server.js. The file looks like this:

require("babel-core/register")
require("./server.js")

When requiring babel-core/register, it automatically compiles required files on the fly. And we start that file with nodemon like this:

nodemon server.babel.js

Nodemon automatically restarts the server when there is a code change.

Templating on backend

Generating the HTML out of React components on the server is a one-liner:

const app = ReactDomServer.renderToString(component)

We put this generated HTML inside an HTML template where we define html, body tags, etc. We do that by using a template engine called handlebars.

A simplified version of our template looks like this:

<!DOCTYPE html>
<html>
  <head>
  </head>
<body>
  <script>
    window.__INITIAL_STATE = {{{initialState}}};
  </script>
  <div id="app">{{{app}}}</div>
</body>
</html>

The generated HTML is injected at {{{app}}}. This is the place where we also mount the React components on the frontend.

We use the template from express like this:

return res.render("appTemplate", {
  app,
  initialState,
})

The first argument is the template name. The second argument is the variables to inject into the template.

Did you notice that initialState variable? Continue reading to find out what that is!

Data fetching on initial load

The application is a webshop which shows product information. That product info is stored in backend systems that we fetch over REST API. We obviously want the data to be loaded as quickly as possible on page load.

We don’t do any Ajax calls on page load in our components. Instead, we use Redux and we define an initial state that we use when creating the store on the backend. The initial state is fetched from our backend APIs and it contains product information, cart data, subscription information, etc.

We create our Redux store with the initialState on the backend like this:

const initialState = {
  // data such as product info, config, etc
}

// the intitialState is sent to createStore:
const store = createStore(reducers, initialState)

const componentWithStore = (
  <Provider store={store}>
    <Component />
  </Provider>
)

const html = ReactDomServer.renderToString(componentWithStore)

The initialState variable is also sent to the frontend in a global variable so that the client side app gets the exact same state prefetched. It looks like this:

const initialState = window.__INITIAL_STATE // same initial state we use on the server side rendered version

const store = createStore(reducer, initialState)

ReactDOM.render(
  <Provider store={store}>
    <Component />
  </Provider>,
  element
)

This solution to fetch data on page load for both the server side rendered HTML, and to the client is the fastest and the easiest to maintain we have found. This approach is very well documented in the Redux docs.

CSS

We are using an old-fashioned solution for CSS: we use less and we write it outside our JS app. No fancy css-in-js for us.

We have separated our LESS build out of webpack. We call less as part of our build process that we call from an NPM script. In devmode it’s a bit different though. We run LESS and CSS through webpack, so that we get hot reloading of CSS.

Our CSS setup is one thing that we set up early in the project and have never touched since. We have not had any issues when it comes to SSR with this simple solution.

Routing

We have not implemented the webshop as a SPA. That means we do all the routing on the backend.

The process for setting up a new web page is:

  1. Create a new entry in route table

  2. Create a new controller

  3. Fetch the data from backends and initialize state, render React components, and send to template enginee (as described previously).

As you can see there is some amount of boilerplate for setting up new routes (even though we have abstracted away much of the boilerplate in 3.)

But one advantage with this solution is that is simple and easy to reason about.

Code splitting

Implementing both lazy loading of bundles and SSR is a difficult task.

So we have picked one of them only: SSR.

We do split our bundle. We have one bundle with dependencies, and one with the production code. That has no impact on the complexity of SSR.

Avoiding referencing window, document, etc on server

When doing server side rendering, your frontend code must now be able to run by node. Node and the browser are not 100% identical. One main difference is that node does not use the global variables window and document. So if your frontend code depends on window or document being defined, you will get runtime errors on the backend that looks something like this:

ReferenceError: window is not defined

The easiest way to resolve this is to use an if to avoid running those lines of code on the backend. Those lines never affect how the React components are rendered anyway so we can safely skip running them.

We have the functions isRunningOnClientside and isRunningOnServerside that we have defined in a util library. They are implemented like this

function isRunningOnServerSide() {
  return typeof window === "undefined"
}
isRunningOnClientSide() {
    return typeof(window) !== "undefined";
};

Whenever we need to reference something that only should be referenced on client we guard it with an if, for example

function sendToGa(data) {
  if (util.isRunningOnServerSide()) {
    return
  }
  window.dataLayer.push(data)
}

Note that we use the guard clause pattern which highlights the main flow of the code and gets rids of the error flow early. It also reduces the need for nested ifs.

Here are some examples of variables that we guard against on server side:

  • window.location.search
  • window.history.replaceState
  • this.isTouchEnabled = 'ontouchstart' in document.documentElement;
  • Google analytics (window.dataLayer, window.google_tag_manager)
  • Facebook analytics (window.fbq)

The process for knowing when to add the guard is something like:

  1. Write code
  2. Watch the terminal for errors, if we see an error related to window or document being undefined then
  3. Introduce the guard

We are also careful to only use dependencies that work with SSR. Usually, we have to find out if they do by testing them.

What about Next.js for doing SSR?

Next.js is a framework for server-side rendered React applications. We have evaluated but it we have decided not to use it.

When we started working on this project Next.js didn’t exist. It got released when the code base was pretty big already. Rewriting a large application to using Next.js is a big task. And we have other areas of our code base that gives us much more value to do large refactorings of.

Even if Next.js would have existed at the start of this project, I don’t think we would have used it. When making technology decisions for a critical application that a medium sized team will work on for many years, I want to think about:

  • The technology should still be relevant and have an active community in the future
  • I don’t want to be locked in. It should be possible to change any library/framework/platform.

I think Next.js will still be relevant in 5 years from now, but I what I am worried about is that Next.js is difficult to move away from if you would want to in the future. It’s an opinionated framework that dictates how you should write your code. This gives a lockin-effect that I want to avoid for this application.

Conclusion

Creating an SSR solution that we are happy with has not been an easy journey. We have spent a lot of time understanding how SSR works and what implementation is the best for our use case. We have also tried and failed a lot along the way.

I believe that the key to our success with SSR is that we have kept it simple.

That we have simplified has helped us a lot. We don’t do client-side routing. We don’t do dynamic loading of code. We have a simple way of doing the loading of initial data.

We could have implemented all that because it’s cool and hip technologies. But we have been very careful not to make things more complicated than it had to be.

Do you want to implement SSR in your application? Start here.

Follow me on Twitter to get real-time updates with tips, insights, and things I build in the frontend ecosystem.


tags: