How to preview a SPA + API app before merging a PR
Published: 2022-06-30 19:03:14

## The problem

How to test a SPA + REST API app before merging the pull request, if its parts are hosted in multiple platforms?

We have multiple teams working on separate projects at Seasoned. All of them consist of a React SPA on Netlify plus a Rails API on Heroku. All the code is on Github.
In the beginning of 2022, we moved each project to its monorepo and changed our CI to use Github Actions.

![Code and deployment structure](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ri8cbk9myc7jd9n3n7d7.png)
 
Whenever a dev opened a PR, if it changed only the SPA code, we would have a nice Netlify [deploy preview](https://docs.netlify.com/site-deploys/deploy-previews/) automatically. Reviewers could use a version of the app instead of relying just on unit tests and screenshots. Devs could open draft PRs to ask for help and others could see what they were seeing without needing to clone the branch. 

But if the API code had changed, there was no way to test it live. We'd need to either merge it to staging, or the reviewer had to checkout the branch locally and run the whole app. This meant more friction, longer QA cycles, and fewer people helping each other across teams.

![Deploy preview only when SPA changed](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/chf0ay8m6tytu13sopkr.png)

## What we want

Heroku has a nice feature called [review apps](https://devcenter.heroku.com/articles/github-integration-review-apps). It seems to do what we need: it creates a temporary app for each open PR. The challenge becomes making Netlify preview and Heroku review apps talk to each other, and having something on the database so anyone can test the review app easily without doing tons of setup.

The end result we want looks like this:
- Dev opens a pull request
- Netlify creates a deploy preview
- Heroku review apps deploys the API
- Action leaves a comment on the PR with a link to the SPA
- Anyone on the team can look at the PR, click a link, and use the app as if it were the staging environment.

We need a few things in order to have a working app:

1. - SPA knows the API URL
2. - API knows the SPA URL
3. - API has the staging database

Here's how we solved each of these challenges.


### How to make the SPA know the correct API URL

React knows the API URL from an env var set on Netlify.

```javascript
// App.js
process.env.REACT_APP_API_URL
```
We needed to make this value be generated at build time.
None of the Netlify environment variable options seemed flexible enough for this use case.

Time for some good ol' Javascript. I changed the API URL inside React to a function:

```javascript
// App.js
const getBaseURL = () =>
  window?.env?.REACT_APP_API_URL ?? process.env.REACT_APP_API_URL
```
In other words, if there is a global value for the API URL, use that one instead of the environment.

And added this to `public/index.html`:
```html
<!-- index.html -->
<head>
...
    <script src="%PUBLIC_URL%/env-config.js"></script>
...
</head>
```

This script needs a single line:
```javascript
// public/env-config.js
window.env = { REACT_APP_API_URL: '<some-api-url>' }
```

Now all we need to do is create a `public/env-config.js` at build time and the SPA will use that API URL. We do not need to change anything for staging and production, since those already have their env configured correctly.

Github Actions give you full control, so it's easy to run some shell script to prepare that file. We do need a predictable API URL, and fortunately Heroku Review Apps gives us that. We chose this format: 
`https://<staging-api-app>-pr-<pr-number>.herokuapp.com`

Here's how we did it inside a Github Action workflow:

```yml
# .github/workflows/deploy-preview.yml
name: Deploy preview

on: 
  pull_request:

defaults:
  run:
    working-directory: web # This is a monorepo, only run if the SPA directory has changes

env:
  staging-api: <heroku-staging-app-name>
  netlify-app: <netlify-staging-app-name>
...
# Do all your Node setup, caching, etc
...
  build-and-deploy:
    name: Build and deploy
    needs: install
    runs-on: ubuntu-latest
    steps:
...
# Checkout code, install, etc
...
      - name: Set API app and Netlify alias from PR
        run: |
          echo "REACT_APP_API_URL=https://${{ env.staging-api }}-pr-${{ github.event.number }}.herokuapp.com" >> $GITHUB_ENV
          echo "NETLIFY_ALIAS=deploy-preview-${{ github.event.number }}" >> $GITHUB_ENV

      - name: Build and deploy to netlify
        run: |
          echo "Using API URL: ${{ env.REACT_APP_API_URL }}"
          echo "Using Netlify alias: ${{ env.NETLIFY_ALIAS }}"
          echo "window.env = { REACT_APP_API_URL: '${{ env.REACT_APP_API_URL }}' }" > public/env-config.js
          export NETLIFY_AUTH_TOKEN=${{ secrets.NETLIFY_AUTH_TOKEN }}
          export NETLIFY_SITE_ID=${{ secrets.NETLIFY_SITE_ID }}
          npx netlify deploy --build --context deploy-preview --alias ${{ env.NETLIFY_ALIAS }} -m "PR ${{ github.event.number }} ${{ github.head_ref }} commit ${{ github.sha }}"
```

It's also nice to comment a link to the deploy on the PR:
```yml
     - name: Comment link to deploy
        if: github.event.name == 'pull_request'
        uses: marocchino/sticky-pull-request-comment@v2.2.0
        with:
          header: deploy-preview
          recreate: true
          message: |
            Deploy preview link: <https://${{ env.NETLIFY_ALIAS }}--${{ env.netlify-app }}.netlify.app>
            Latest commit deployed: ${{ github.sha }}
```

Check the [full Deploy Preview workflow](https://github.com/SeasonedSoftware/sample-spa-app/blob/main/.github/workflows/deploy-preview.yml). It has some more things than what was shown here.


### How to create Heroku Review apps


We followed the [docs](https://devcenter.heroku.com/articles/github-integration-review-apps) and enabled Review Apps from the Heroku Dashboard itself. We used the same env vars as the staging environment. The URL pattern must match the one the Github action uses: `https://<staging-api-app>-pr-<pr-number>.herokuapp.com`

Review apps don't have a default setup, so we needed to add an [app.json file](https://devcenter.heroku.com/articles/github-integration-review-apps#configuration). It must live in the root dir.

We use the inline buildpack strategy from [this article](https://jtway.co/deploying-subdirectory-projects-to-heroku-f31ed65f3f2) in order to deploy only the `api` folder. The `git push subtree` strategy doesn't work here because we're using the automatic review app deploy.

We need to do a few things after the initial configuration:
- The API needs the staging database
- The API needs to know the SPA URL

The best place we found to make that was inside the [postdeploy script](https://devcenter.heroku.com/articles/github-integration-review-apps#the-postdeploy-script). Time for some more shell scripting. By the way, [shellcheck](https://www.shellcheck.net/) made this so much easier.

```json
/* app.json */
{
  "environments": {
    "review": {
      ...
      "scripts": {
        "postdeploy": "bin/postdeploy"
      },
      ...
  }
}
```

This script is great because it runs only when the app is created, and not on subsequent pushes. That means we won't be reseting the database all the time.

We chose to copy the Staging database instead of preparing a DB seed because our projects have already been running for years. Preparing and maintaining a seed would be a lot of work, while the teams are already setup to use staging.

Using Postgres, it was pretty straightforward to copy the whole staging database:
```bash
# api/bin/postdeploy.sh
psql -c "DROP SCHEMA public CASCADE; CREATE SCHEMA public; GRANT ALL ON SCHEMA public TO public;" "$DATABASE_URL"
pg_dump -Fc "$STAGING_DATABASE_URL" | pg_restore --verbose --no-acl --no-owner --dbname "$DATABASE_URL"
```

We used the [platform API](https://devcenter.heroku.com/articles/platform-api-reference) to configure the PR-dependent environment variables:

```bash
# api/bin/postdeploy.sh
curl -n -X PATCH https://api.heroku.com/apps/$HEROKU_APP_NAME/config-vars \
  -d "{
  \"REACT_APP_URL\": \"https://deploy-preview-$HEROKU_PR_NUMBER--<netlify-staging-app.netlify.app\" \
  -H "Content-Type: application/json" \
  -H "Accept: application/vnd.heroku+json; version=3" \
  -H "Authorization: Bearer $HEROKU_API_KEY"
```

We need one last thing: to run Rails migrations again, since we droped any migration changes the PR might have introduced.

```bash
# api/bin/postdeploy.sh
rake db:migrate
```

It works, but it takes minutes to build the review app. Let's use a [shared cache buildpack](https://elements.heroku.com/buildpacks/fintual/heroku-buildpack-shared-build-cache) to speed things up. We set the staging app name to an env var and now our build copies the cache from there, then runs the bundler and updates anything that it needs.

Check the full code: [app.json](https://github.com/SeasonedSoftware/sample-spa-app/blob/main/app.json), [postdeploy script](https://github.com/SeasonedSoftware/sample-spa-app/blob/main/api/bin/postdeploy).

## Testing

Now all we need to do is open a PR. Heroku will deploy the review app, Github actions will deploy the SPA, and we'll receive a nice comment on the PR with the link to see the app running :tada:

There is a caveat. Since we're using the free Heroku PG add-on, the Review App will have limitations. At the time of writing, this limit is 10k rows in the DB. Once this limit is reached, the DB will disable write operations after 7 days. But even if your PR stays open that long, you can close and reopen it to recreate the review app and it will work again.