3-step process to deploying SPA and API monorepo using Heroku postbuild

Someone on Stackoverflow asked a question about how to run npm run build and have the backend serve the frontend app as part of the Heroku deploy pipeline. In other words, how to deploy a frontend-backend monorepo on Heroku. As I had pieced the solution together when I was porting Lindy to Heroku, I wrote a brief answer. This post is a more detailed guide to setting everything up.

karls/spa-with-heroku
How to build and serve a frontend app with a backend app on Heroku - karls/spa-with-heroku
👆 Reference implementation on Github

The basic principles behind building the frontend app as part of your deploy and subsequently serving it via the backend app boil down to:

  1. using correct buildpacks on Heroku;
  2. instructing Heroku's build process to compile the frontend app before compiling the app slug and caching dependencies for subsequent builds
  3. ensuring that the compiled frontend assets end up in a directory where the backend app expects them.

1. Using correct buildpacks

Heroku will automatically detect the buildpack to use using certain heuristics. E.g if your app is a vanilla Python app, Heroku will automatically detect the correct buildpack (Python) for your app.

However, since we're also trying to compile the frontend assets, we are going to have to tell Heroku to use the JavaScript buildpack in addition to the buildpack for your backend language.

Adding a buildpack is pretty straightfoward, you can do it either via the Heroku CLI or via the Heroku Dashboard. Here's what the buildpacks for Lindy look like. Note: the ffmpeg buildpack is specific to Lindy, you likely don't need it.

Buildpacks in app settings after adding the NodeJS and ffmpeg buildpacks

2. Use Heroku postbuild to compile the JavaScript app and cache dependencies

The next step is to compile the frontend app as part of the build process on Heroku. Add a package.json to the root of your project if you don't have one already. The two key things needed in package.json are the instructions for building your frontend app and caching the node_modules directory.

For instance, if your frontend app is located in client/, here's what package.json in the project root should look like (other keys omitted).

{
  ..
  "scripts": {
    "heroku-postbuild": "cd client && yarn install && yarn run build"
  },
  "cacheDirectories": [
    "client/node_modules"
  ]
  ..
}
package.json in the project root

Heroku will automatically run the specified command(s) in heroku-postbuild — in this instance yarn install and yarn run build. Swap those out as needed for npm. It'll also cache the node_modules in the client/ directory as per cacheDirectories directive.

3. Ensuring compiled files end up in the right place

It's good practice to .gitignore compiled files. Your build step should output the compiled-for-production files (i.e main.{js,css}) into a directory where your backend will know where to expect them, both in development and in production.

For instance, with Lindy, in development, main.{js,css} are being continuously compiled by Rollup as the source files are updated. In production, Rollup will compile, tree-shake and uglify main.js, and purge and minify main.css into the same directory as in development. In other words, the production app will find the relevant files in the same directory as in development, except in production they're compiled for production by the Heroku build process.

This is what a typical directory structure in such a scenario might look like. Roughly speaking, the build step in package.json compiles files from client/src into server/static, which is where the server will know to look for them.

⌞ package.json
  ⌞ client
    ⌞ src
    ⌞ node_modules
    ⌞ ...
  ⌞ server
    ⌞ static
    ⌞ src
    ⌞ ...

Reference implementation

An example repository implementing the pointers in this guide, with plain JavaScript bundled with Rollup, served by a Flask app, is available on Github.