Deploy React apps for review on every PR in Gitlab CI/CD

Creating a pipeline for previewing new frontend versions on pull requests

Introduction

Review is an essential part of the software development process. However, looking at the code is not sufficient to determine if everything works as expected. What I like to do when reviewing frontend apps is to have an option to click through some of the changes.

Many teams use UAT or other dev environments to be able to do that. However, these have to be set up and often are not maintenance free. For years now we've been able to deploy our static frontend apps to Github Pages or Gitlab Pages. It would be great to be able to deploy to them on all pull requests, wouldn't it?

Currently, Pages don't offer hosting of multiple versions of the app. However, Gitlab has so-called browsable artifacts that in principle work in the same way. I'm going to show you how to use them in order to be able to deploy your frontend app every time a pull request is created. We are going to integrate it with the review apps feature of GitLab.

Additionally, our main branch is going to be deployed to GitLab pages. You can remove this part if it's not what you're looking for.

Create a new react app

Skip this step if you already have one. We're going to generate an app via CRA.

First, create a directory for our project.

mkdir frontend-review-apps

Then generate the app boilerplate in the client folder. I am assuming you may want to store some other parts of the app like the backend or serverless functions in the project folder as well.

npx create-react-app client

After the app is generated, we're going to add React Router, so the example is a bit more realistic. Version 6 is the latest at the time of writing. The configuration in the latter parts of the article should be similar for future and past versions.

npm install --save react-router-dom@6

Then we have to make our App.js actually use router's features.

import React from "react";
import {
  BrowserRouter as Router,
  Routes,
  Route,
  Link
} from "react-router-dom";

export default function App() {
  return (
    <Router>
      <div>
        <nav>
          <ul>
            <li>
              <Link to="/">Learn React</Link>
            </li>
            <li>
              <Link to="/about">About</Link>
            </li>
            <li>
              <Link to="/users">Users</Link>
            </li>
          </ul>
        </nav>

        <Routes>
          <Route path="/about" element={ <About /> } />
          <Route path="/users" element={ <Users /> } />
          <Route path="/" element={ <Home /> } />
        </Routes>
      </div>
    </Router>
  );
}

function Home() {
  return <h2>Home</h2>;
}

function About() {
  return <h2>About</h2>;
}

function Users() {
  return <h2>Users</h2>;
}

Let's run it locally and confirm it works!

npm start

Preparing the app

We need to make one adjustment in order to be able to deploy our app under a subdirectory (example.com/subdirectory) in GitLab pages as our app URL will look like https://horosin.gitlab.io/frontend-review-apps/. By default the router is not prepared for such case.

Sadly, using browsable artifacts this way won't work as they don't behave completely the same as a full-on static file web server. You will be able to access the app only starting from the root URL. If anyone works around this, please let me know!

To do that just alter the line 11 of App.jsx to look like the following.

    <Router basename={process.env.PUBLIC_URL}>

It won't break anything if the variable is empty and will allow us to add a path prefix that we need.

Scripting the CI/CD pipeline

I like to structure and modularize my Gitlab CI config. Here's my main .gitlab-ci.yml file. It makes sure that on pull requests only one pipeline is run. The file also imports CI config for the client.

image: node:16


workflow:
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
    - if: '$CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS'
      when: never
    - if: '$CI_COMMIT_BRANCH'


stages:
  - build
  - test
  - deploy


include:
  - local: .gitlab-ci/client.yml

Client specific config is stored in .gitlab-ci/client.yml. I am using a few tricks to avoid repetition: .cache, .client-job and .pages_deploy_job are reused in this config.

Most of the pipeline is pretty standard. This yaml defines build and test steps that are going to run before the deployment job.

In the .pages_deploy_job we are altering the package.json file.

sed -i '5i\  "homepage": "'"$HOMEPAGE"'",' ./package.json

This line sets PUBLIC_URL environment variable and makes sure react imports js and other files using appropriate paths.

We are also copying index.html to 404.html in order to make routing work when sharing a particular subpage. The page for review is set to be live for a week. It is visible only to people that are able to view the project. We are also describing the environment, so that its visible in the project's Deployments > Environments section and in pull requests.

stop review app: job is there to allow GitLab to remove the environment from the list after merging the pull request.

.cache: &client_cache
  key: $CI_COMMIT_REF_SLUG
  paths:
    - client/.npm

.client-job:
  variables:
    NODE_ENV: production
  before_script:
    - cd client && npm i --production=false --cache .npm --prefer-offline
  cache:
    <<: *client_cache
    policy: pull


build-client-job:
  stage: build
  extends: .client-job
  script:
    - CI=true npm run build
  cache:
    <<: *client_cache


test-client-unit-job:
  stage: test
  extends: .client-job
  script:
    - CI=true npm test


.pages_deploy_job:
  extends: .client-job
  stage: deploy
  script:
    - |
        sed -i '5i\  "homepage": "'"$HOMEPAGE"'",' ./package.json
    - CI=true npm run build
    - rm -rf public
    - mv build public
    - cp public/index.html public/404.html
    - mv public ../public

  artifacts:
    expire_in: 1 week
    paths:
      - public

  variables:
    HOMEPAGE: ""


review app:
  extends: .pages_deploy_job

  variables:
    HOMEPAGE: "/-/$CI_PROJECT_NAME/-/jobs/$CI_JOB_ID/artifacts/public"

  except:
    - main

  environment:
    name: review/$CI_COMMIT_REF_SLUG
    url: "https://$CI_PROJECT_NAMESPACE.gitlab.io/-/$CI_PROJECT_NAME/-/jobs/$CI_JOB_ID/artifacts/public/index.html"
    auto_stop_in: 1 week


stop review app:
  stage: deploy
  image: alpine

  script: echo "stop review app for $CI_COMMIT_REF_NAME"

  environment:
    name: review/$CI_COMMIT_REF_SLUG
    action: stop

  rules:
    - when: never


pages:
  extends: .pages_deploy_job

  variables:
    HOMEPAGE: "/$CI_PROJECT_NAME"

  only:
    - main

  environment:
    name: pages
    url: $CI_PAGES_URL

Test it!

Let's see it in action. Firstly, create a new branch.

git checkout -b home-page-content

Change something in the home page, e. g.:

function Home() {
  return (<Fragment>
    <h2>Home</h2>
    <p>Welcome.</p>
  </Fragment>);
}

Let's upload it to gitlab.

git add .
git commit -m "Update homepage content"
git push --set-upstream origin home-page-content

And create a merge request. You should see the pipeline running.

image.png

After it finishes, it should change to this.

image.png

Click the "View app" button to test it! Our review app should be up and running.

Summary

I hope this was a useful tutorial for you. It took me some time to figure out how to do this properly and I couldn't find an end to end example. Making apps available to reviewers in this way was very useful to me and my coworkers.

This article didn't focus on the backend of the app. To make it available to review you may need to do a standard deployment in your environment. Otherwise you need to use mocks or if your API is changing rarely, you can probably connect to an already deployed version.

If you decide to use it in your project and it's public, make sure to share it in the comments.

Full code: gitlab.com/horosin/frontend-review-apps

Did you find this article valuable?

Support Karol Horosin: AI, Engineering & Product by becoming a sponsor. Any amount is appreciated!