loading...
Cover image for Developing a Website with React, Feature Flags, Netlify and GitHub Actions

Developing a Website with React, Feature Flags, Netlify and GitHub Actions

timon profile image Timon van Spronsen Updated on ・11 min read

Please note: this post was written for GitHub Actions v1. GitHub Actions v2 has been released which deprecates the HCL syntax for workflows which renders some parts of this blog post unusable. To learn more about GitHub Actions v2, please refer to the documentation and the excellent posts by Edward Thomson.

In recent weeks I've helped develop a website for a very exciting project at Awkward called Coffee by Benjamin. Coffee by Benjamin is a coffee roasting kit that allows anyone to roast their coffee at home, this guarantees the freshness of the coffee. The project will launch on Kickstarter soon. If you'd like to keep notified about this project you can follow them on Instagram or visit the website.

This project is my last one at Awkward as I'll be taking up a new challenge at another company soon. Even though I won't be a part of the project going forward, I still want to share something about the way we've been developing and shipping the website by utilizing React, feature flags, Netlify, and GitHub Actions.

Problem statement

The website will launch in three separate phases outlined below. We're currently in phase 1 but we're nearing completion on phase 2. Meanwhile, we've already started development on phase 3.

  • Phase 1: a simple landing page where people can fill in their email address to get notified when the project launches.
  • Phase 2: a full-fletched website which contains more information about the project, a FAQ and a support form. This will launch together with the launch of the Kickstarter campaign.
  • Phase 3: integrate Shopify into the website to sell the product directly. This will launch after the project has been successfully funded and shipped.

Even though phase 3 won't launch till much later, we wanted to start development on this phase as soon as possible because it's the most complicated part of the website to build. This allows us to start testing the shop functionality long before it's launched and catch costly bugs from creeping into the website.

Now we could build phase 3 in a separate branch, but we'd have to constantly update and solve merge conflicts on this branch when we update the phase 2 website. This is especially hard because there are a lot of overlapping parts that we'll change in phase 3. Furthermore, this would result in having to merge a gigantic pull request when phase 3 launches which comes with the risk of bugs in existing functionality. Instead, we want to gradually merge functionality from phase 3 in the main branch without exposing it to the public. We also want the team to be able to check the progress on both phase 2 and phase 3. Finally, we'd like to completely exclude any code from phase 3 while phase 2 is live so that we don't ship any unnecessary code.

In the rest of the post I'll explain how we used a combination of feature flags, Netlify and GitHub Actions to achieve these goals.

The tray

The tray you'll get as part of the home roasting kit

Feature flags

The problem statement just screams for feature flags, which is exactly what we'll be using. Feature flags allow us to ship parts of phase 3 but don't actually show them to the public. Let's take a look at a definition of feature flags:

Release Toggles allow incomplete and un-tested codepaths to be shipped to production as latent code which may never be turned on.

Feature Toggles, Pete Hodgson

The nice thing about feature flags is that it allows you to switch between new and old functionality with the flip of a switch. Usually you do this by wrapping new functionality in a condition like so:

function Header() {
  if (USE_NEW_FEATURE) {
    return <NewHeader />;
  }

  // feature flag is not enabled
  return <OldHeader />;
}

In code that's affected by a feature flag, you'll add new code without replacing the old code. This allows pull requests with new but overlapping functionality to be merged as they won't replace any existing functionality. Later when the feature flag is being phased out you can remove the conditions and remove any old code.

Let's see how we can implement this into our stack.

Feature Flags in Create React App

We can implement feature flags by using environment variables which Create React App supports out of the box. The benefits of using environment variables are that they're easy to use and they're compile-time constants, which means that code that is guarded by a condition that checks for the flag being enabled will be completely excluded from a build where the flag was disabled.

Environment variables in Create React App can be supplied in a .env file. The .env file will contain the default value to use and is checked into Git and will only be changed when phase 3 goes live.

.env:


REACT_APP_SHOPIFY_INTEGRATION_ENABLED=false

Now we can use the feature flag in App.js to conditionally render the shop routes. By conditionally rendering the shop routes using a compile-time constant, the code won't end up in the production bundle unless the flag is enabled and users won't be able to route to these pages. The code for the pages will still end up in the production bundle, more on that later.

src/App.js:

import React, { Suspense } from 'react';
// ... more imports hidden
import Home from 'pages/Home';
import Shop from 'pages/shop';
import Cart from 'pages/cart';
import ProductDetail from 'pages/product-detail';

const App = () => (
  <Router>
    <Switch>
      <Route exact path="/" component={Home} />
      <Route path="/faq" component={Faq} />
      <Route path="/support" component={Support} />
      {process.env.REACT_APP_SHOPIFY_INTEGRATION_ENABLED === 'true' && (
        <>
          <Route path="/shop" component={Shop} />
          <Route path="/cart" component={Cart} />
          <Route path="/product/:productId" component={ProductDetail} />
        </>
      )}
    </Switch>
  </Router>
);

ReactDOM.render(<App />, document.getElementById('root'));

Now that we've got the feature flag set up developers can add a .env.local (or any of the other supported .env files) which won't be checked into git.

.env.local:

REACT_APP_SHOPIFY_INTEGRATION_ENABLED=true

Configuring Netlify

Now only developers can see the Shopify integration by checking out locally and changing the environment variable in .env.local, what about other people that might want to review the site with a simple link? This is where Netlify comes in. Netlify allows developers to configure the build settings per branch and all branches will be deployed with a unique URL (separately from deploy previews), I'll let the Netlify documentation speak for itself:

Branch deploys are published to a URL which includes the branch name as a prefix. For example, if a branch is called staging, it will deploy to staging--yoursitename.netlify.com.

NOTE: You may have to manually set the branch deploys setting to deploy all branches, this is explained in the Netlify documentation.

We can add a branch in Git called shop-staging and configure netlify.toml to build this branch with the REACT_APP_SHOPIFY_INTEGRATION_ENABLED feature flag enabled.

netlify.toml:

[build]
  publish = "build"
  command = "npm run build"

[context."shop-staging"]
  command = "REACT_APP_SHOPIFY_INTEGRATION_ENABLED=true npm run build"

Prefixing the build command with REACT_APP_SHOPIFY_INTEGRATION_ENABLED=true will override the settings in .env. The site with the feature flag enabled will now be automatically deployed to shop-staging--yoursitename.netlify.com. We can now give this URL to testers and they'll be able to check out the progress on phase 3 and they can still check out the progress on phase 2 by visiting develop--yoursitename.netlify.com. You can also use this approach to enable the feature flag for deploy previews for certain pull requests.

There's still one problem though, the shop-staging branch will have to be to kept in sync with the main branch (in our case develop). Luckily, GitHub provides an extensive API which provides a way to do a fast-forward update for a branch, this allows us to keep the shop-staging branch in sync with the develop branch. All we have to do is provide it the ref we want to update (heads/shop-staging) and a commit SHA of the latest commit on the develop branch and then shop-staging will be in sync with the develop branch. Furthermore, we can automate this process by using GitHub Actions!

Creating a GitHub Action to keep branches in sync

GitHub actions, just like shell commands, are extremely composable. There's a lot you can accomplish by composing a few predefined actions. In this case we technically only need the Filter action and the cURL action. But I couldn't get the cURL action to accept a JSON body with an interpolated value, so we'll be creating our own.

There are two ways to create GitHub Actions, you can create a separate repository that contains the Action, this way other projects will be able to reuse the Action. But for something small that you won't reuse you can create an Action right inside of the repository where the rest of the code for your project lives.

We first create a folder .github, inside of it we create a folder called branch-sync-action. We must then create a Dockerfile, the contents are copied from the cURL action, we just change some of the labels. This Dockerfile ensures that we can use cURL which we'll use to do the HTTP call.

.github/branch-sync-action/Dockerfile

FROM debian:stable-slim

LABEL "com.github.actions.name"="Branch Sync"
LABEL "com.github.actions.description"=""
LABEL "com.github.actions.icon"="refresh-cw"
LABEL "com.github.actions.color"="white"

COPY entrypoint.sh /entrypoint.sh

RUN apt-get update && \
    apt-get install curl -y && \
    apt-get clean -y

ENTRYPOINT ["/entrypoint.sh"]

Next, we create an entrypoint.sh which is the script that will be executed when running the action.

.github/branch-sync-action/entrypoint.sh

#!/bin/sh

TARGET_BRANCH=$1

curl \
  -X PATCH \
  -H "Authorization: token $GITHUB_TOKEN" \
  -d "{\"sha\": \"$GITHUB_SHA\"}" \
  "https://api.github.com/repos/$GITHUB_REPOSITORY/git/refs/heads/$TARGET_BRANCH"

$1 stands for the first argument provided to the script. For clarity we give it the name TARGET_BRANCH.

Don't forget to provide execution permissions by doing chmod +x entrypoint.sh.

That's it for the action itself. Now we have to hook it up in a workflow:

.github/main.workflow

workflow "Sync shop-staging branch with develop" {
  on = "push"
  resolves = ["Sync Branch"]
}

action "Filter develop branch" {
  uses = "actions/bin/filter@master"
  args = "branch develop"
}

action "Sync Branch" {
  needs = ["Filter develop branch"]
  uses = "./.github/sync-branch-action"
  secrets = ["GITHUB_TOKEN"]
  args = ["shop-staging"]
}

In .github/main.workflow we define workflows for our project. Workflows decide which actions to run and when. In the workflow block we tell it when to run by defining the on attribute, in our case the workflow should run for every push event, we also define the actions it should execute (in parallel) by defining the resolves attribute.

Next, we define the filter action. GitHub will send a push event for every push to any branch, we want to add a filter so that we only sync the shop-staging branch when someone pushes to the develop branch, we're not interested in pushes to any other branch. In the uses parameter we point to the slug of the GitHub repository that provides this action and in this case the folder within this repository (filter). The @master part tells it to use the code that was published on the master branch.

Finally we add the action that syncs the shop-staging branch with the develop branch. It has the needs parameter defined which tells GitHub Actions that it should first run the filter action and only continue with Sync Branch if the filter action succeeds. Furthermore we define the uses parameter which will point to the folder containing the Dockerfile and entrypoint.sh which is used by GitHub Actions to run it. We also pass it the GITHUB_TOKEN as a secret which we need to make an authenticated HTTP call, GITHUB_TOKEN is a uniquely generated token for every project on GitHub. Lastly, we provide the arguments for entrypoint.sh which is the target branch it should sync to.

We'll end up with a flow looking like this:

Workflow

It's important to note that the sync is one-way only. Everything that's pushed to develop will be fast-forwarded to shop-staging, if you're pushing to shop-staging nothing will happen, it will cause problems with future synchronization because updates can't be fast-forwarded anymore. You can solve this by enabling the force parameter in the cURL request or by resetting the shop-staging branch using git reset.

Lazy loading shop routes

A last problem we still have to tackle is excluding phase 3 related code from the bundle while phase 2 is live. We can tackle this by utilizing some new features released in React just last year: React.lazy and Suspense. The changes we have to make to our code are quite minimal, we have to change the way we're importing the shop pages by utilizing React.lazy and dynamic imports:

src/App.js:

import React, { Suspense } from 'react';
// ... more imports hidden
import Home from 'pages/Home';
const Shop = React.lazy(() => import('pages/shop'));
const Cart = React.lazy(() => import('pages/cart'));
const ProductDetail = React.lazy(() => import('pages/product-detail'));

const App = () => (
  <Suspense fallback={<div>Loading...</div>}>
    <Router>
      <Switch>
        <Route exact path="/" component={Home} />
        <Route path="/faq" component={Faq} />
        <Route path="/support" component={Support} />
        {process.env.REACT_APP_SHOPIFY_INTEGRATION_ENABLED === 'true' && (
          <>
            <Route path="/shop" component={Shop} />
            <Route path="/cart" component={Cart} />
            <Route path="/product/:productId" component={ProductDetail} />
          </>
        )}
      </Switch>
    </Router>
  </Suspense>
);

ReactDOM.render(<App />, document.getElementById('root'));

Now the shop pages will not end up in the main bundle, they will instead be lazily loaded when a user hits one of the shop routes. Which is impossible when the flag is not enabled. All of the routes are wrapped in a Suspense component which is responsible for showing a fallback state when visiting one of the lazily loaded routes as it still takes some time to download the bundle. If you'd like to know more about code-splitting (in React) I can recommend the excellent React documentation.

Demo

I created a simplified example of the code in this post which you can check out here: https://github.com/TimonVS/sync-branch-demo. You can clone it and push a commit to the master branch to see that the shop-staging branch will automatically be kept in sync.

Conclusion

We're quite happy with this approach. GitHub Actions deem to be very flexible. It'd have been even simpler if Netlify would support this use case out of the box, but since that's not the case syncing two branches isn't too bad either.

The approach described in this post can also be used when using split-testing which is built into Netlify and allows you to test two (or more) variants of a website. It's not something we're using ourselves but with split-testing come the same problems as described in the problem statement.

Finally, I have to note that we're currently only using one feature flag. This approach might not scale well if you want to use lots of feature flags because you might want to deploy separate staging sites for all combination of flags.

Happy roasting!

Posted on by:

timon profile

Timon van Spronsen

@timon

I'm a front-end developer. I love building stuff with React, Node.js, TypeScript and Elixir.

Discussion

markdown guide
 

Great article!

A note about feature flags: using env vars means that you're missing a very big aspect of feature flags IMO - the ability to release features without building and deploying a new version. If you have a mechanism that allows you to enable feature flags at runtime, you can do things like a/b testing, gradually releasing a feature and in essence you are separating the "feature release" from the application deployment, which can help it become a product decision instead of a technical one.
For example, if you have to coordinate a feature release with a PR announcement, this can help you turn on a feature without the need for deployment.

There are several tools that can help you achieve this:

  • Tweek is an open source tool (disclaimer: I work at Soluto which developed it) github.com/Soluto/tweek
  • LaunchDarkly is a managed tool launchdarkly.com
  • Firebase remote config is another managed one

Thank you for the article!

 

Hey Oded, this post was meant to demonstrate a very light-weight solution for feature flags. I agree they're not nearly as flexible as using runtime feature flags, but they also introduce downsides like having to pay for a service or hosting your own. This is totally worth it if you're building a (complex) web app though. By the way, with Netlify it's completely possible to do A/B testing by using feature flags in env variables :)

 

Didn't know about A/B testing env vars with Netlify, very cool! I'll check it out.

Firebase has a free tier if I'm not mistaken and I think AWS also has something. The integration effort is very small and it's worth it even for simpler apps IMO.