Rendering and serving a Create-React-App from an Express server running within a Lambda function

2018-04-25 - Yatin Badal

Serverless architecture has emerged as one of the hottest solutions in recent times. Its ease-of-use, speed and simplicity make it a clear winner when it comes to rapid application development. The Serverless Framework extends on these core values and provides a solid ecosystem for developing Serverless applications with fantastic support and tooling. With a few lines of YAML markup and Javascript, you’re able to interactively deploy infinitely scalable*, publicly available APIs or services atop AWS’s Lambda Functions.

As a fun, little, proof-of-concept, I thought it would be interesting to see if I could get a Lambda function running an Express.js server to render and serve (Server-Side Render) a non-ejected Create-React-App (CRA) build.

Right, let’s get into it, we need to:

  1. Bootstrap a Serverless application
  1. Setup Express.js
  1. Render and serve a CRA

Prerequisites

  • Serverless framework — you can find installation instructions here. Be sure to also configure your AWS credentials.
  • Node 8.10

Bootstrap

Let’s start by creating a new project directory and initializing it with NPM

$ mkdir my-project && cd my-project
$ npm init -f

Create a serverless.yml and index.js file

$ touch serverless.yml index.js

Next, let’s pull in Express.js and serverless-http

$ npm i -S serverless-http express

We also want to install the serverless-webpack plugin which will help use all the shiny new JS. The serverless-offline plugin will allow us to deploy our Serverless stack locally. We will use webpack-node-externals to exclude node_modules from our build.

$ npm i -D webpack serverless-webpack serverless-offline webpack-node-externals

Configure Serverless

Within serverless.yml, place the following YAML:

//serverless.yml

service: my-projectplugins:
  - serverless-webpack
  - serverless-offlinecustom:
  webpack:
    webpackConfig: ./webpack.config.js
    includeModules: true
    packager: 'npm'provider:
  name: aws
  runtime: nodejs8.10
  stage: dev
  region: eu-west-1functions:
  app:
    handler: index.handler
    events:
      - http: ANY /
      - http: 'ANY {proxy+}'
      - cors: true

Integrate Express

Inside index.js place the following:

// index.js
import serverless from "serverless-http";
import express from "express";

const app = express();

app.get("/", function(req, res) {
  res.send("Hello World!");
});

export const handler = serverless(app);

Configure Webpack

Create a webpack.config.js file

$ touch webpack.config.js

We will be using babel to transpile our code, so we will need a pull in the necessary loaders and packages

$ npm i -D babel-core babel-loader babel-plugin-source-map-support babel-preset-env

Add the following webpack config

// webpack.config.js
const path = require("path");
const slsw = require("serverless-webpack");
const nodeExternals = require("webpack-node-externals");

module.exports = {
  entry: slsw.lib.entries,
  target: "node",
  mode: slsw.lib.webpack.isLocal ? "development" : "production",
  optimization: {
    // We no not want to minimize our code.
    minimize: false
  },
  performance: {
    // Turn off size warnings for entry points
    hints: false
  },
  devtool: "nosources-source-map",
  externals: [nodeExternals()],
  module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /node_modules/,
        use: [
          {
            loader: "babel-loader"
          }
        ]
      }
    ]
  },
  output: {
    libraryTarget: "commonjs2",
    path: path.join(__dirname, ".webpack"),
    filename: "[name].js",
    sourceMapFilename: "[file].map"
  }
};

Right, let’s give this a test and say hello to all the world

$ sls offline start

You should see the following output

Serverless: Bundling with Webpack...
Time: 645ms
Built at: 2018-04-20 00:41:52
       Asset       Size  Chunks             Chunk Names
    index.js   4.69 KiB   index  [emitted]  index
index.js.map  922 bytes   index  [emitted]  index
Entrypoint index = index.js index.js.map
[./index.js] 469 bytes {index} [built]
[express] external "express" 42 bytes {index} [built]
[serverless-http] external "serverless-http" 42 bytes {index} [built]
Serverless: Watching for changes...
Serverless: Starting Offline: dev/eu-west-1.Serverless: Routes for app:
Serverless: ANY /
Serverless: ANY /{proxy*}
Serverless: (none)Serverless: Offline listening on http://localhost:3000

Visit http://localhost:3000 in your browser and you should see Hello World!

Create React App

Next, let’s create our client — a create react app (CRA) in my-project root, use the create react app cli

$ create-react-app client

This should create a client directory containing a react application.

Express Middleware

We will use an Express middleware to do all the heavy lifting and render our react app.

Create a directory called middleware

$ mkdir middleware

Inside middleware, create a renderer.js file

$ touch renderer.js

Add the following code

// renderer.js
import fs from "fs";
import path from "path";
import React from "react";
import ReactDOMServer from "react-dom/server";
// import main App component
import App from "../client/src/App";

export default (req, res, next) => {
  // point build index.html
  const filePath = path.resolve("client", "./build", "index.html");

	// read in html file
  fs.readFile(filePath, "utf8", (err, htmlData) => {
    if (err) {
      return res.send(err).end();
    }
    // render the app as a string
    const html = ReactDOMServer.renderToString(<App />);    

		// inject the rendered app into our html and send it
    return res.send(
      // replace default html with rendered html
      htmlData.replace('<div id="root"></div>', `<div id="root">${html}</div>`)
    );
  });
};

Make sure you also install all the react dependecies

$ npm i -S react react-dom react-scripts

Great, now that we’ve created our renderer middleware, let’s update index.js and use our renderer

// index.js
import serverless from "serverless-http";
import express from "express";
import path from "path";
// import middleware
import renderer from "./middleware/renderer";

const app = express();
// root (/) should always serve our server rendered page
app.use("^/$", renderer);// serve static assets
app.use(express.static(path.join(__dirname, "client", "./build")));

// handler
export const handler = serverless(app);

We’re almost at the finish line. Let’s make a few changes to our webpack config first. Install the following loaders and plugins

$ npm i -D babel-plugin-css-modules-transform babel-preset-es2015 babel-preset-react-app babel-preset-stage-2 babel-preset-env url-loader copy-webpack-plugin

Update webpack.config.js to the following config

// webpack.config.js
const path = require("path");
const slsw = require("serverless-webpack");
const nodeExternals = require("webpack-node-externals");
const CopyWebpackPlugin = require("copy-webpack-plugin");module.exports = {
  entry: slsw.lib.entries,
  target: "node",
  mode: slsw.lib.webpack.isLocal ? "development" : "production",
  optimization: {
    // We no not want to minimize our code.
    minimize: false
  },
  performance: {
    // Turn off size warnings for entry points
    hints: false
  },
  devtool: "nosources-source-map",
  externals: [nodeExternals()],
  module: {
    rules: [
      {
        test: /\.js$/,
        loader: "babel-loader",
        include: __dirname,
        exclude: /node_modules/,
        query: {
          presets: ["es2015", "react-app", "stage-2"],
          plugins: ["css-modules-transform"]
        }
      },
      {
        test: /\.(png|jp(e*)g|svg)$/,
        use: [
          {
            loader: "url-loader",
            options: {
              limit: 8000,
              name: "images/[hash]-[name].[ext]"
            }
          }
        ]
      }
    ]
  },
  plugins: [
    new CopyWebpackPlugin([{ from: "client/build", to: "build" }], {
      debug: "info"
    })
  ]
};

We employ the copy-webpack-plugin in order to copy our client build to the bundle we plan to deploy to AWS.

Test Locally

To deploy our serverless application locally, run

$ sls offline start

Visit http://localhost:3000 in your browser and you should see the default create react app :D.

Deploy

In order to deploy our amazing new serverless application to AWS, we first need to update the client package.json . Add the homepage property with the value “/dev”. This is so that our react app is aware of the base path enforced by API Gateways stages.

// client/package.json
{
  "name": "client",
  "version": "0.1.0",
  "private": true,
  "homepage": "/dev",
  "dependencies": {
    "react": "^16.3.2",
    "react-dom": "^16.3.2",
    "react-scripts": "1.1.4"
  },
  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test --env=jsdom",
    "eject": "react-scripts eject"
  }
}

Finally, let’s build and deploy

$ npm run build --prefix client && sls deploy -v

If everything goes according to plan, our create react app will build, webpack will bundle everything together and Serverless will orchestrate our serverless architecture as well as upload our bundle to a Lambda.

If Serverless successfully deploys your application, you should see your newly created Lambda function as well as API Gateway endpoint in the output.

Service Information
service: my-project
stage: dev
region: eu-west-1
stack: my-project-dev
api keys:
  None
endpoints:
  ANY - https://t4xx50pfme.execute-api.eu-west-1.amazonaws.com/dev
  ANY - https://t4xx50pfme.execute-api.eu-west-1.amazonaws.com/dev/{proxy+}
functions:
  app: my-project-dev-app

Navigate to your newly created endpoint and you should see your react app, which has been rendered and served from an Express app running inside a Lambda function.

Closing

We have successfully leveraged AWS Lambda in order to render and serve a create react app. This, obviously, might not be the best solution, but it definitely is an interesting one. Serverless architecture is an amazing technology and has great potential. So go forth and do more interesting things with serverless, today!

References