Martin McKeaveney I'm a software engineer who is passionate about building cloud native web and mobile applications using React, Svelte, AWS and more.

The best of both worlds: SSR with isomorphic JavaScript

Zero to one serverless-side rendering with AWS Lambda and AWS Amplify

11 min read 3312

Server-side rendering, or SSR, is a phrase that you hear often in the frontend development community.

At the most basic level, server-side rendering is exactly what it describes: rendering applications on the server. You navigate to a website, it makes a request to the server, it renders some HTML, and you get the fully rendered result back in your browser. Fairly straightforward. You may be asking yourself why the community even has a buzzword for this.

Before the rise of rich and dynamic web applications that relied heavily on JavaScript and jQuery, essentially all web applications were server-rendered. PHP, WordPress, and even just basic HTML sites are examples of this.

When you visit a page on one of these sites, you get back all the HTML — data and all. If you click a link, the browser will make another request to the server. Following the response, the browser will refresh and render the next page from the ground up. This approach works well, and it has for years; browsers are spectacularly fast at rendering static HTML. What has changed?

Since the turn of the century, JavaScript usage has gone from a little sprinkle here and there for webpage interactivity to the undisputed language of choice on the web. We are constantly shipping more logic and JavaScript to the browser.

Single-page frameworks like React and Vue have ushered in this new era of dynamic, complex, data-driven client-rendered web applications. These SPAs differ from server-rendered applications because they do not fetch fully rendered content complete with data from the server before rendering on the screen.

Client-side-rendered applications render their content in the browser using JavaScript. Rather than fetching all of the content from the server, they simply fetch a barebones HTML page with no body content and render all the content inside using JavaScript.

The benefit of this is that you avoid the full page refreshes that happen with fully server-rendered applications, which can be a little jarring for the user. Single-page client-rendered apps will update content on your screen, fetch data from APIs, and update right in front of you without any kind of page refresh whatsoever. This characteristic is what makes modern web applications feel snappy and “native” as you interact with them.

Client-side rendering trade-offs

It’s not all sunshine and rainbows in the client-side-rendered SPA world. There are some trade-offs that come with rendering your application on the client side. Two primary examples are SEO and initial load performance.

SEO

Since client-rendered apps return a bare-bones HTML page with very little content before JavaScript kicks in and renders the rest, it can be difficult for search engine crawlers to understand the HTML structure of your page, which is harmful to the search rankings of your site. Google has done a lot of good work around this, but it’s still recommended to avoid client-side rendering if SEO is particularly important.

We made a custom demo for .
No really. Click here to check it out.

Initial load performance

With client-rendered apps, you generally see the following things happen when you first open the page:

  • The app loads some basic HTML, like an app shell or static navbar
  • You see a loading indicator of some sort
  • Your content is then rendered

The issue with this is that your application won’t show anything until the JavaScript loads completely from the network and is finished rendering elements onto your screen.

In a nutshell, the problem with client-side performance in general is that you cannot control what client device someone uses your application on — whether it be their state-of-the-art smartphone, powerful high-end desktop machine, or $100 lower-end smartphone.

We do, however, control the server. We can almost always give our server more CPU and memory and tweak it to make it perform better for our users.

The best of both worlds

We can have the best of both worlds when using server-side rendering with modern frontend technologies. The way this generally works is that the server renders and sends back the fully rendered application on first load. The next step, known as hydration, is where your JavaScript bundle will be downloaded and executed. This attaches event handlers and wires things up like your client-side router.

With this approach, you get all the benefits of SSR on the initial load, then every interaction from that point forward will be handled by client-side JavaScript. This provides for a speedy, SEO-friendly initial load, followed by the dynamic single-page web app experience that we know and love.

Applications like this are known as universal applications because the same JavaScript runs on the client and the server. You may also hear the fancier term “isomorphic” being used, which means exactly the same thing.

Tutorial: Implementing SSR

SSR is not without its trade-offs, either. It adds overhead to your development by introducing a more complex configuration as well as having to host and manage your own server. These problems are why incredible frameworks like Next.js and Razzle are very popular: they abstract away the SSR configuration part and let you focus on writing UI code.

In this tutorial, we are not going to use any SSR frameworks. The best way to learn how something works is by actually building it, so we are going to learn how to create the simplest SSR setup we possibly can that will provide:

  • Global CDN
  • Fully functional backend API
  • No servers or infrastructure to manage
  • Single-command deployment

We’re going to deploy a universal server-rendered React application created with create-react-app on Amazon Web Services (AWS). You don’t need to have experience with AWS to follow along.

Our tools

In order to build our application, we are going to make use of a few different AWS services.

  • AWS Amplify: A high-level framework for managing AWS services, mainly for mobile and web development
  • AWS Lambda: Run code in the cloud without managing servers
  • AWS Cloudfront (CDN): A content delivery network responsible for delivering and caching content all over the world
  • AWS Simple Storage Service (S3): Where we’ll store our static assets (JS, CSS, etc.)

Architecture diagram

Our Lambda function is responsible for server-rendering our React application. We will be using S3 to store our static content and Cloudfront CDN to serve it. You do not need to have any prior knowledge of these services, as AWS Amplify will make it super simple for us to create them.

Our Architecture Diagram

Building our application

First of all, you need to install the AWS Amplify CLI and create an AWS account if you don’t have one already. You can do so by following this short guide.

Project setup

Now that Amplify is configured, we can start setting up our React project. We are going to use the fantastic create-react-app to help us. Assuming that you have Node.js and npm installed, we can run:

npx create-react-app amplify-ssr
cd amplify-ssr 
yarn add aws-amplify 
amplify init

Select the default options in the AWS Amplify wizard.

Our React project is now bootstrapped with Amplify and ready for us to add our “server” for SSR. We do this by running amplify add api and answering some questions:

$ amplify add api

? Please select from one of the below mentioned services: REST
? Provide a friendly name for your resource to be used as a label for this category in the project: amplifyssr
? Provide a path (e.g., /items): /ssr
? Choose a Lambda source: Create a new Lambda function
? Provide a friendly name for your resource to be used as a label for this category in the project: amplifyssr
? Provide the AWS Lambda function name: ssr
? Choose the function runtime that you want to use: NodeJS
? Choose the function template that you want to use: Serverless expressJS function
? Do you want to access other resources created in this project from your Lambda function? N
? Do you want to edit the local lambda function now? N
? Restrict API access: N
? Do you want to add another path? N

This will create the relevant templates, directories, and code needed for our AWS infrastructure and backend: an AWS Lambda function that will run a small Express server responsible for rendering our React application.

Before we deploy our infrastructure, there are some changes we need to make inside our React application to prepare it for server-side rendering. Open up src/App.js (the main app component for your React application) and paste in the following:

import React from 'react';

function App() {
  return (
    <div className="App">
      <header className="App-header">
        Server Rendered React App
      </header>
    </div>
  );
}

export default App;

Next, we need to create a script that will render our React application on the server side. This is done with the renderToString function in the react-dom/server package. This function is responsible for taking our <App /> component and rendering it on the server side as a string, ready to be returned as fully rendered HTML to the client.

Create a file at src/render.js with the following code:

import React from "react";
import { renderToString } from "react-dom/server";
import App from "./App";

export default () => renderToString(<App />);

Great — our client-side React app has all the code it needs to be rendered on the server side. This means we now must code up the server-side endpoint that will render our React application.

We have a problem, though — we need the src/render function and our <App /> component code to be run on the server side. The server does not know anything about React or even ES modules by default. For this reason, we are going to transpile the code from the React application using Babel into the server side.

To do this, let’s install a few Babel dependencies into our project.

yarn add --dev @babel/core @babel/cli @babel/preset-react @babel/preset-env

Next, create a .babelrc at the root of your project. This file is used to configure Babel and tell it which plugins/presets to use.

{
    "presets":[
        "@babel/preset-react",
        "@babel/preset-env"
    ]
}

Finally, let’s update our package.json to transpile our code as part of the build step. This will transpile the files into the amplify/backend/function/amplifyssr/src/client directory, which is where we will store all the universal JavaScript that needs to be run on the client side as well as the server for SSR.

  "scripts": {
    "start": "react-scripts start",
    "transpile": "babel src --out-dir amplify/backend/function/amplifyssr/src/client --copy-files",
    "build": "npm run transpile && react-scripts build && npm run copy",
    "copy": "cp build/index.html amplify/backend/function/amplifyssr/src/client",
    "test": "react-scripts test",
    "eject": "react-scripts eject"
  },

Rendering the app in Lambda

The build configuration is complete! Let’s jump into amplify/backend/function/amplifyssr/src and install react and react-dom, as they will both be required for the Lambda to perform SSR.

yarn add react react-dom

Now to configure our Express server, which will run on Lambda. The Lambda function was auto-generated for us when we completed the amplify add api step earlier and chose a REST and ExpressJS API.

Amplify has already configured the Express server for us to run on Lambda, so all we need to do now is add an endpoint to server-render our React application when someone hits the API URL in the browser. Update your amplify/backend/function/amplifyssr/src/app.js file to contain the following code:

/* Amplify Params - DO NOT EDIT
    ENV
    REGION
Amplify Params - DO NOT EDIT */

const express = require('express')
const bodyParser = require('body-parser')
const awsServerlessExpressMiddleware = require('aws-serverless-express/middleware')
const fs = require('fs');
const render = require('./client/render').default;

// declare a new express app
const app = express()
app.use(bodyParser.json())
app.use(awsServerlessExpressMiddleware.eventContext())

// Enable CORS for all methods
app.use(function(req, res, next) {
  res.header("Access-Control-Allow-Origin", "*")
  res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept")
  next()
});

app.get('*', function(req, res) {
  // Read the index.html file from the create-react-app build
  const html = fs.readFileSync("./client/index.html", "utf-8");
  // Server side render the react application
  const markup = render();

  // Replace the empty body of index.html with the fully server rendered react application and send it back to the client
  res.send(html.replace(`<div id="root"></div>`, `<div id="root">${markup}</div>`))
});

module.exports = app

Our Express server is now SSR-ready, and we can deploy our React application.

Hosting and final touches

Once we receive the server-rendered HTML back from our app’s initial render, we will then fetch the client-side JavaScript bundle to take over from there and give us a fully interactive SPA.

We need somewhere to host our client-side JavaScript and static files. In AWS, the service generally used for this is S3 (Simple Storage Service), a massively scalable cloud object store.

We are also going to put a CDN in front of it for global caching and performance. With Amplify, we can create both of these resources for our project by running a few commands from our project root directory:

$ amplify add hosting

Select the plugin module to execute Amazon CloudFront and S3
? Select the environment setup: PROD (S3 with CloudFront using HTTPS)
? hosting bucket name (name your bucket or use the default)

You can now deploy your entire infrastructure, including your Express server Lambda function, S3 bucket, and CDN, by running the amplify publish command.

Your console output will show all the relevant resources from your templates being created for you by Amplify. Please note that creating a Cloudfront CDN can take a while, so be patient. Once your resources are created, your Cloudfront CDN URL will be displayed in the terminal.

Publish started for S3AndCloudFront
✔ Uploaded files successfully.
Your app is published successfully.
https://d3gdcgc9a6lz30.cloudfront.net

The last thing we need to do is tell React from where to fetch our client-side bundle after the app is rendered by the server. This is done in create-react-app using the PUBLIC_URL environment variable. Let’s update our React app package.json scripts again to look like the following:

  "scripts": {
    "start": "react-scripts start",
    "transpile": "babel src --out-dir amplify/backend/function/amplifyssr/src/client --copy-files",
    "build": "npm run transpile && PUBLIC_URL=<your-cloudfront-url> react-scripts build && npm run copy",
    "copy": "cp build/index.html amplify/backend/function/amplifyssr/src/client",
    "test": "react-scripts test",
    "eject": "react-scripts eject"
  },

Rebuild and deploy your application to AWS with this updated configuration.

amplify publish

We should now have a fully server-side-rendered React app running on AWS!

Running our app

Your SSR API URL can be found at amplify/backend/amplify-meta.json. Look for the RootUrl in your JSON file and you should see the URL at which you can visit your new server-rendered application. It should look something like the following:

"output": {
    "ApiName": "amplifyssr",
    "RootUrl": "https://g6nfj3bvsg.execute-api.eu-west-1.amazonaws.com/dev",
    "ApiId": "g6nfj3bvsg"
}, 

Visit the API Gateway URL in your browser at <your-api-url>/ssr and you should see your shiny new server-rendered React application! If you dive into the Network tab in your browser of choice and view the requests, you will notice that the request to /ssr has a fully rendered HTML response with our React application rendered inside the <body> of the document.

<div id="root">
  <div class="App" data-reactroot="">
      <header class="App-header">Server Rendered React App</header>
  </div>
</div>

You will also notice the requests being made to your Cloudfront URL from the browser to load the client-side JavaScript that will take over rendering from here, giving us the best of both the client-side and server-side rendering worlds.

Where to go from here

This tutorial is intended to get you up and running with server-side rendering as quickly as possible without worrying about managing infrastructure, CDNs, and more. Having used the serverless approach, there are a few nice enhancements we can make to our setup.

Provisioned concurrency

One way that AWS Lambda is able to remain extremely low-cost is that Lambda functions that have not been hit in a while will go “idle.” This essentially means that when we execute them again, there will be what’s known as a “cold start” — an initialization delay that must happen before the Lambda responds.

After this, the lambda is then “warm” again for a period of time and will respond to subsequent requests quickly until the next long idle period. This can cause slightly unreliable response times.

Despite being “serverless,” Lambda uses lightweight containers to process any requests. Every container can process only one request at any given time. In addition to the cold-start problem after an idle period, the same is also true when many concurrent requests hit the same Lambda function, causing more concurrent containers or workers to be cold-started before responding.

In the past, a lot of engineers have solved this issue by writing scripts to ping the Lambda periodically to keep it warm. There is now a much better AWS-native way to solve this, and it’s known as Provisioned Concurrency.

With Provisioned Concurrency, you can very easily request a given number of dedicated containers to stay warm for a specific Lambda function. This will give you a much more consistent SSR response time in times of high and sporadic load.

Lambda versions

You can create several Lambda versions for your functions and divide traffic between them. This is very powerful in our SSR application as it allows us to make updates on the Lambda side and A/B test them with a smaller portion of users.

You can publish several versions of your Lambda and divide traffic between them in weights that you specify. For example, you may want to server-render a CTA banner for some users to measure engagement, but not render it for others. You can do this with Lambda versions.

Full-stack web application

As explained previously, AWS Amplify already creates a REST API and an Express server for us, in which we have created an endpoint to server-render our React application. We can always add more code and endpoints to this Express server at amplify/backend/function/amplifyssr/src/app.js, enabling us to turn our app into a full-stack web application, complete with database, authentication, and more.

You can make use of the fantastic suite of AWS Amplify tools to create these resources or plug into your own infrastructure — even if it isn’t hosted on AWS. You can treat your AWS Lambda backend as any other Express server and build on top of it.

You already have your whole deployment pipeline set up by running amplify publish so you can focus on writing code. The starting point in this tutorial gives you total flexibility to do what you want from here.

Conclusion

Server-side rendering doesn’t have to be hard. We can use fully managed tools like Next or Razzle, which are amazing in their own right, but for a lot of teams this can be much too large a paradigm shift given their existing code or requirements. Using a simple, low-maintenance, custom approach can make life easier, especially if you are already using AWS or Amplify for your project.

SSR can add a ton of value to your web applications and provide a much-needed performance or SEO boost. We are fortunate in the web development community to have tools that can create CDNs, serverless backends, and fully hosted web applications with a few commands or clicks.

Even if you don’t think you need SSR, it’s a very prevalent and common topic in the JavaScript ecosystem. Having an understanding of its benefits and trade-offs will come in handy to almost anyone involved in the web development sphere.

I hope you learned something today — thanks for reading! Feel free to reach out to me or follow me on Twitter, where I tweet and blog about JavaScript, Python, AWS, automation, and no-code development.

Full visibility into production React apps

Debugging React applications can be difficult, especially when users experience issues that are difficult to reproduce. If you’re interested in monitoring and tracking Redux state, automatically surfacing JavaScript errors, and tracking slow network requests and component load time, try LogRocket.

LogRocket is like a DVR for web apps, recording literally everything that happens on your React app. Instead of guessing why problems happen, you can aggregate and report on what state your application was in when an issue occurred. LogRocket also monitors your app's performance, reporting with metrics like client CPU load, client memory usage, and more.

The LogRocket Redux middleware package adds an extra layer of visibility into your user sessions. LogRocket logs all actions and state from your Redux stores.

Modernize how you debug your React apps — .

Martin McKeaveney I'm a software engineer who is passionate about building cloud native web and mobile applications using React, Svelte, AWS and more.

Leave a Reply