How to create a simple, secure feature flag system in next.js

Flags blowing in the wind on a beach
Photo by Samantha Woodford on Unsplash

Feature flags are a valuable tool when developing new features for an application. They are especially useful if a feature takes a long time to complete, because they allow you to merge unfinished code into develop/main. This helps avoid long-running feature branches, which will cause a lot of merge conflicts and other pain in the long run.

In this article we will be taking a look at how to implement a basic, but secure feature flag system in next, without using any external services or libraries.

What do I mean by secure?

Secure in this context means that the code under the feature flag will not be shipped to any clients, until the feature flag is enabled for that environment.

For example:

import NewRegistration from 'components/NewRegistration'
import OldRegistration from 'components/OldRegistration'

const Registration = (props: { title: string }) => {
  return (
    <div>
      {__FF_REGISTRATION_REDESIGN__
        ? <NewRegistration />
        : <OldRegistration />
      }
    </div>
  )
}

We want to avoid that the code from the <NewRegistration /> component will be shipped to clients in production until it has been finished and tested, to avoid possible security issues.

Add a new environment variable to every environment

Unless you have a similar thing already, you need to create an environment variable representing the current environment. For example:

# For local development
ENV=local

# For staging
ENV=staging

# For production
ENV=production

Make sure the ENV variable is available at build time in every environment. Depending on your setup, you may have to add it to your CI script, an .env file, your Vercel environment variables, etc.

Note: Using NODE_ENV is not suitable for this, as it can only be set to ‘development’ or ‘production’.

Setting up the flags

Now, we’ll create a file containing the feature flags. It doesn’t matter where the file is located, in my case it’s src/featureFlags.js. It has to be a JS file, no Typescript, since it needs to be called from the next.config.js, which doesn’t support Typescript at this time.

const getEnv = () => {
  const env = process.env.ENV;
  if (env === "local") return "local";
  if (env === "staging") return "staging";
  // Important: make sure the default case returns the
  // production environment. This is to make sure that if
  // someone forgets to add the environment variable, only
  // the most tested feature flags will be enabled.
  return "production";
};

const flags = {
  // Feature flags, and in which environments they are enabled
  __FF_NEW_API_VERSION__: ["local"],
  __FF_REGISTRATION_REDESIGN__: ["local", "staging"],
};

module.exports = Object.keys(flags).reduce((acc, key) => {
  acc[key] = flags[key].includes(getEnv());
  return acc;
}, {});

In this file, I have defined two feature flags: __FF_NEW_API_VERSION__, which is only enabled in the local environment, and __FF_REGISTRATION_REDESIGN__ which is enabled in both the local and the staging environment.

I recommend you give your feature flags unique names, because they will be globally substituted by webpack, which means things will break if you use that name for something else like a variable name.

Note: You need to restart your dev server for changes in the feature flags file to be applied.

Editing the next config

Now we need to edit or create the next.config.js with these additions:

const webpack = require("webpack");
const flags = require("./src/featureFlags.js");
const serializedFlags = Object.keys(flags).reduce((acc, key) => {
  acc[key] = JSON.stringify(flags[key]);
  return acc;
}, {});

module.exports = {
  webpack(config) {
    config.plugins.push(new webpack.DefinePlugin(serializedFlags));
    return config;
  },
};

Here we are using the webpack DefinePlugin to substitute our feature flag strings (e.g. __FF_NEW_API_VERSION__) with boolean literals (true or false). This means that when we build for the staging environment, our example from above will be transformed to:

import NewRegistration from 'components/NewRegistration'
import OldRegistration from 'components/OldRegistration'

const Registration = (props: { title: string }) => {
  return (
    <div>
      {false ? (
        <NewRegistration />
      ) : (
        <OldRegistration />
      )}
    <div>
  )
}

This code can now be statically optimized and webpack tree-shaking will fully remove the code from the <NewRegistration /> component from the bundle.

Typing the global feature flags

If you’re using typescript (as you should), you will be getting type errors when using the ‘magical’ global variables we defined with webpack. You can create a type declaration file (ending with d.ts) that declares these globals.

Mine is called src/global.d.ts and looks like this:

declare var __FF_NEW_API_VERSION__: boolean;
declare var __FF_REGISTRATION_REDESIGN__: boolean;

This tells typescript about the two global variables and declares them project-wide.

However, we now have a separate file we have to update every time we change our feature flags. To make sure there is only one source of truth, we can automatically generate this decaration file before every build. To do this, first make a script that writes the file (I called mine typegen-globals.js):

/* eslint-disable @typescript-eslint/no-var-requires */
const fs = require("fs");
const flags = require("./src/featureFlags");
const path = require("path");

// Write typescript definitions for global variables
const definitions = Object.keys(flags)
  .map((name) => `declare var ${name}: boolean;`)
  .join("\n");

fs.writeFileSync(
  path.join(__dirname, "src/globals.d.ts"),
  "// This file is automatically generated, do not edit manually!\n",
);
fs.appendFileSync(path.join(__dirname, "src/globals.d.ts"), definitions);

When you run this script with node typegen-globals.js, it will automatically generate the declaration file from your feature flag definitions:

// This file is automatically generated, do not edit manually!
declare var __FF_NEW_API_VERSION__: boolean;
declare var __FF_REGISTRATION_REDESIGN__: boolean;

To make it run before every build, you can easily hook into the next predev and prebuild commands in your package.json:

{
  ...
  "scripts": {
    "dev": "next dev",
    "predev": "node typegen-globals.js",
    "build": "next build",
    "prebuild": "node typegen-globals.js",
  },
  ...
}

Verifying the results

You can easily verify that the code is removed from the bundles by searching them with grep. Take our example from before and replace the new component with a unique string (or put the string anywhere into the component code):

const Registration = (props: { title: string }) => {
  return (
    <div>
    {__FF_REGISTRATION_REDESIGN__
      ? 'XYZ123'
      : <OldRegistration />
    }
    </div>
  )
}

Now you can build for different environments and compare the results.

$ ENV=local npm run build
$ grep -l -R XYZ123 .next/static
.next/static/chunks/pages/index-81914f2591db49cd.js

As expected, the code will be included in the bundle when building for an environment that has the flag enabled. Now if you do the same thing for the production environment, which doesn’t have the flag enabled:

$ ENV=production npm run build
$ grep -l -R XYZ123 .next/static
# no output

You will not be getting any matches anymore, since all code was tree-shaken out of the bundle.

Caveats

As with everything code, this approach also has some downsides.

Removing stuff at build time means that in order for changes to the flags to be applied, the app needs to be — duh — rebuilt. Therefore, the feature flags can’t be changed with a cloud toggle or similar. One needs access to the repository and a whole new build needs to be rolled out. You could add a cloud toggle, but still have to rebuild for the changes to be visible.

Another problem is that the feature flag constants are somewhat magical, meaning they don’t have to be imported from anywhere, which I personally dislike. There is probably a solution involving generating a copy of the flags file in full typescript, from which you could import, but I haven’t felt the need to go there yet.