Avatar of Aarnav PaiAarnav Pai

Implementing Feature Flags from Scratch

Implementing feature flags in your application? You’ve come to the right place! This guide covers a few different types of feature flags and how to set up your application to use them.

Feature flags are a technique to enable or disable certain features in your application during runtime, without deploying new code. Feature flags can also be enabled for a subset of users based on certain conditions, making them a very powerful tool for testing new features with a small subset of users (termed gradual roll-out), to find bugs and get early feedback, before making those features public for everyone to use (Generally Available).

Feature flags can also be used to test a new feature in production. A developer can set a feature flag on their account, and test out the feature on the production website with real production data, without affecting the experience of others! This also allows developers to merge to the main branch more frequently, avoiding merge conflicts, and allowing them to refactor the feature as the codebase changes.

There are many different SaaS providers out there, for example, LaunchDarkly, DevCycle, PostHog, and many others, but there are a few reasons you'll be building them from scratch.

Greater control

First, you'll have more control over your flags. Since they run on your infrastructure, with your code, and your data, it will be really easy for you to make changes without having to look up the provider's documentation.

Faster performance

Second, it's faster on your infra. With a third-party provider, you have to make HTTP requests to their endpoint, which may be located on the other side of the world, but you can provision your infra to have your flags' database be located in the same network as your server.

Deeper understanding

Finally, building something from scratch lets you learn a lot about that topic, rather than using a pre-baked solution offered by someone else. Knowing more about a topic helps you make more informed choices related to it, say if you were to choose a third-party provider instead in the near future.

You'll be made to clone a starter app, a simple twitter clone written in Next.js. Next.js or React are not required to implement feature flags, but I've chosen them since they're the most popular web development frameworks.

You'll be using feature flags in many different ways within this app, and I recommend that you apply the things you’ve learned on your own side-project or website after this.

This application uses Postgres to store its data, which includes user accounts, posts, comments, likes, etc. Postgres is a very popular SQL database which is fit for storing such relational data.

In this article, I will be using Redis to store data related to feature flags. Redis is chosen because it is simple, fast, and easy, but you can use any other database you wish, including the existing Postgres database.

  • Node.js 22 with the package manager of your choice (I'll use pnpm).
  • A Postgres (to store the app’s data) and Redis (to store feature flags and cache) instance for local development. (This article uses Docker).
  • A railway account
  • Railway CLI

To save time, and keep this guide short and concise, I've created a small simple Twitter clone in Next.js, in which we will be implementing feature flags. Let's clone this app, and observe its code.

Run the below commands to clone this repository to your local machine:

git clone https://github.com/arnu515/feature-flags-demo
cd feature-flags-demo  # change to cloned directory

Then, run pnpm install (or whatever other Node package manager you prefer, I'll use pnpm from here on) to install its dependencies.

This app uses Postgres to store data. If you do not already have an available postgres instance, you can easily create one with Docker:

docker run --name twitter-clone-db -dp 5432:5432 --env POSTGRES_PASSWORD=postgres postgres

Now, you should execute the seed.sql file in the project to create the tables that the app uses.

cat seed.sql | docker run -i twitter-clone-db psql -U postgres --dbname=postgres

If you're not using docker, you can directly pass the file to psql with the --file parameter.

Let's start a development server by running pnpm dev. You can visit the twitter clone at http://localhost:3000, and play around with it. It only has text posts, and likes don't work yet. You'll be adding some more features to it and use feature flags to gradually roll them out.

Now let's get this starter app deployed for the internet to see.

This step is essential for you to test out feature flags, since it is not possible to get the user's IP on a locally hosted page.

If you haven’t already, you need to install the Railway CLI for this step.

First, you need to authenticate the Railway CLI with your Railway account:

railway login

Open the project folder in your terminal and create a new Railway project:

railway init

Follow the instructions and you'd have created a new Railway project.

Let's deploy our app to the internet. Just run the below command in the project folder, and watch the magic happen:

railway up

Next, you need a Postgres database so your app can store its data. With Railway, it's as simple as running:

railway add -d postgre-sql

Head to the Railway Dashboard, and open your new project. You'll see a Postgres database and your NextJS app provisioned and deployed, but they're not yet accessible to the public. Let's make a few changes to our project.

First, add the database's URL as an environment variable to the deployed app. Click on the app's service, go to Variables, click the New Variable button, and add a "Reference" to the DATABASE_URL variable of the Postgres service (reference variable docs).

Adding the DATABASE_URL variable

Adding the DATABASE_URL variable

While we're here, let's also set another environment variable, SESSION_SECRET. This variable is used for encrypting and decrypting the user's session cookie and must be a string of length 32 or more. An easy way to generate this would be to use openssl:

openssl rand -hex 16  # outputs a 32-length pseudorandomly generated string

Don’t forget to deploy your changes by clicking the blue Deploy button!

Next, let’s expose our app to the public internet! Head over to Settings, and either connect your custom domain, or let Railway generate a (sub)domain for you. I’ll opt with the latter. Do note that the app on Railway (by default) runs on port 8080, not 3000.

Generating a domain for the service

Generating a domain for the service

You also need to create the database tables that our app will use. Fortunately railway provides us with an easy way to connect to the database.

Head over to the dashboard and click on the Postgres service. Now, click on the data tab, and click the Connect button. Copy the psql command in the Public Network tab in the dialog that appears. This will allow you to connect to postgres if you have psql installed.

Connecting to Postgres

Connecting to Postgres

Now, run that command, just appending --file seed.sql to it at the end.

PGPASSWORD=<your_password> psql -h <your_proxy>.proxy.rlwy.net -U postgres -p <your_port> -d <your_db> --file seed.sql

If you don't have postgres installed, you can run the below docker command to start a bash shell with the psql command available. Then you can paste the above command there.

cat seed.sql | docker run --rm --network host -it --entrypoint 'psql' -e PGPASSWORD=<your_password> postgres -h <your_proxy>.proxy.rlwy.net -U <your_username> -p <your_port> -d <your_db> --file=-

And with that, your app should now be available and functional at the domain it is being hosted on.

Congratulations, you've just added a new website to the internet!

It's time to add a Redis instance to your app.

Why not use Postgres for everything?

You could use Postgres to store data related to feature flags too, but Redis is much faster and also better-suited for non-structured data like this. It also allows you to separate data generated by the users of your app and data internal to the working of the app, like stuff used for feature flags.

In Redis, we'll be storing two kinds of data —

  1. small metadata related to the user, such as their IP, country, etc
  2. data related to the feature flags themselves, like whether they are enabled or not, or maybe some other preferences that you can set according to your liking

Get started by spinning up a Redis instance locally using Docker with the redis image:

docker run --name redis -dp 6379:6379 redis

Now, use the Redis client ioredis in your app to check if it works correctly. A helper function, getClient() has been provided in src/lib/redis.ts, which creates a new Redis client instance (if there doesn't exist one already) and returns it. Test out the client in src/app/page.tsx (the code of the homepage) and see if it works by adding this code below:

// File: src/app/page.tsx
import PostForm from "@/components/PostForm";
import sql from "@/lib/db";
import { getUser } from "@/lib/session";
import Post, { type PropPost } from "@/components/Post";
import { getClient } from "@/lib/redis";  // NEW
​
function getPosts() {
  // ...
}
​
export default async function Index() {
  const [user, posts] = await Promise.all([getUser(), getPosts()]);
  const redis = getClient();  // NEW
  console.log(await redis.ping());  // NEW
  return (
    // ...
  );
}

Now, go to the home page in your browser, and take a look at the terminal window (NOT the browser console!). If you see a PONG there, it means that the app is able to communicate with Redis successfully!

Now, you need to add Redis to the deployed app too. First, we must provision a Redis service in Railway. That's as easy as running:

railway add -d redis

You need to add the provisioned Redis server's connection URI to the deployed app.

Head over to the app's variables on the Railway dashboard (like you did last time for setting the Postgres connection URI), and create a new variable named REDIS_URL, which should be set to ${{Redis.REDIS_URL}}/0?family=0. The last part, /0?family=0, specifies the default database to be used (0 in this case), and that ioredis should connect to the Redis server via IPv6.

Adding the REDIS_URL variable

Adding the REDIS_URL variable

After applying your changes, you now need to deploy the modified app by running railway up again. Now, visit the hosted app on the internet, and check the logs on railway. You should be seeing a PONG there.

Observing service logs in Railway

Observing service logs in Railway

Congratulations, you’ve now added a Redis server to your app!

Now that you have connected a Redis instance to your app, you can now implement Feature Flags! You'll be introduced to three types of feature flags, namely, ones that -

  • are applied on users by random chance
  • can be manually togged on/off by the developer of the app (useful for implementing features like limited-time drops)
  • apply to users based on their metadata, like location, gender, or whatever else your app collects

This Twitter clone could certainly use an "Edit Tweet" feature. Let's allow users to edit their tweets for the first thirty minutes, and let's roll it out gradually by only allowing a subset of people to access this feature.

This is the most common usage of feature flags, i.e. testing out a feature on a small random sample of users and ensuring that all bugs are squashed before making it available to everyone. This is termed as "A/B Testing", since there are two groups of users – Group "A", those who have access to the feature, and Group "B", those who don't.

Link the random choice code

To keep this guide short and on-point, I've already added most of the code required for the Edit Post feature in the starter repo. You just need to add a link to the edit page on the post screen. Just edit src/app/[slug]/page.tsx, and add the following code to it:

// file: src/app/[slug]/page.tsx

// ...

async function PostId({ postId }: { postId: string }) {
  // ...
  return (
      {/* ... */}
      <div className="flex items-center gap-4 my-4">
        <BackButton />
        <h3 className="text-xl">
          {post.is_comment && <span className="text-gray-500">In reply, </span>}
          <Link href={`/@${post.username}`} className="font-bold">
            @{post.username}
          </Link>{" "}
          says:
        </h3>
        {/* The BELOW code is new! */}
        {post.user_id === user?.id &&
          Date.now() - post.created_at.getTime() <= 1800000 && (
            <Link
              href={`/${post.id}/edit`}
              className="ml-auto mr-4 link !text-white"
            >
              Edit post
            </Link>
          )}
      </div>
      {/* ... */}
  );
)

This adds a small "Edit post" button next to the title of the posts page if the user is the author of the post, and thirty minutes have not passed since the time of creation of the post. Clicking the Edit post button on a post will take the user to the edit page, where they can change the post's content, only if thirty minutes haven't passed.

As of now, this feature will appear for all users, but we want to create a random sample of users instead.

Create the script to generate a random user sample

Suppose we want 30% of users to have access to this feature (Group "A"), and the rest to not (Group "B"). We can write a simple script to create a sample of users and store it in Redis as a Set. A set is an unordered collection of unique strings. This script is very simple:

  • it uses an SQL statement to get a random set of users and takes the first 30% of them
  • it stores these IDs as a Redis Set. If a Set already exists, it deletes it first, so that fresh data is populated instead.

Note: process.exit(0) is required because node hangs indefinitely after completing the function

// file: src/lib/scripts/randomizeEditUsers.ts
import sql from "@/lib/db";
import { getClient, EDIT_FLAG_KEY } from "@/lib/redis";

export async function randomizeUsers() {
  const ids = await sql<{ id: string }[]>`SELECT id FROM users ORDER BY random() LIMIT round((SELECT count(*) FROM users) * 0.3)`;
  const redis = getClient();
  if (await redis.del(EDIT_FLAG_KEY) == 1) console.log("Deleted existing users.");
  console.log("Added", await redis.sadd(EDIT_FLAG_KEY, ...ids.map(i => i.id)), "users.");
  process.exit(0);
}

randomizeUsers();

The EDIT_FLAG_KEY does not yet exist in src/lib/redis.ts, you'll add it next. Let's also add a helper function which checks if a user is in the edit-users set or not:

// file: src/lib/redis.ts
import Redis from "ioredis";

export const EDIT_FLAG_KEY = "flag:edit-users"; // NEW

export function getClient() { /* ... */ }

export async function userCanEdit(userId: string) { // NEW
  const redis = getClient();
  return (await redis.sismember(EDIT_FLAG_KEY, userId)) == 1;
}

Run the script to generate the sample

You can now run the script you created earlier, using the below command. tsx is a tool which transpiles TS to JS, and runs the transpiled output with node. The --env-file-if-exists flag tells node to populate the environment using the .env file, if it exists.

pnpx tsx --env-file-if-exists=.env src/lib/scripts/randomizeEditUsers.ts

Implement the feature flag logic in the app

All that's left to do now is to actually use the feature flag. We must put it in three places:

  1. in the code that shows the button to edit the post
  2. in the edit page
  3. in the server action that is called by the edit function (since a bad actor could directly POST the server action, bypassing the feature flag)

This is really easy, just check if the userCanEdit function returns true for the user's ID like so:

// file: src/app/[slug]/page.tsx
//...
import { userCanEdit } from "@/lib/redis";
//...
async function PostId({ postId }: { postId: string }) {
// ...
  return (
    {/* ... */}
    {post.user_id === user?.id &&
      Date.now() - post.created_at.getTime() <= 1800000 &&
      (await userCanEdit(user.id)) && (  // NEW
        <Link
          href={`/${post.id}/edit`}
          className="ml-auto mr-4 link !text-white"
        >
          Edit post
        </Link>
      )}
	{/* ... */}
  );
}
// file: src/app/[slug]/edit/page.tsx
// ...
import { userCanEdit } from "@/lib/redis";

export default async function Edit({
  params: { slug },
}: {
  params: { slug: string };
}) {
  const user = await getUser();
  if (!user) notFound();
  if (!userCanEdit(user.id)) notFound();
  // ...
}
// file: src/app/[slug]/edit/actions.ts
"use server";
// ...
import { userCanEdit } from "@/lib/redis";

export async function editPost(id: string, unsanitizedContent: unknown) {
  // ...
  if (!user) redirect("/auth");
  if (!userCanEdit(user.id)) notFound();
  // ...
}

And with this, you've implemented a feature gated by a feature flag! See how simple it is to add feature flags to your application, without needing a third party provider and their bloated SDKs?

Test it out

You can test out this feature by logging into the user account which is included in the flag:edit-users Set, and by checking if they are able to edit posts (which were created no more than 30 minutes ago), and a user account which isn't included will not be able to do so. When your app is deployed, you can just change the Set members in Redis (using the SADD, SREM, etc commands), and these changes will be reflected without having to redeploy your app.

Stretch Goal

I leave it as exercise to the reader to convert the script that was used to create the A/B groups into a NextJS endpoint, so that it can be executed just by visiting a webpage.

There has to be some authentication set in place of course, since we don't want just any user to be able to change these groups. You can use something simple, like Basic Authentication, or a secret header, or you can also use Railway's networking to ensure that the endpoint is only executed when it is accessed through the TCP Proxy. If you don't want to do this, you must instead change the .env file to use the production Redis public URL instead of the locally hosted instance. This will be shown later.

Another common usage of feature flags, is using it as a switch by the developer for all users. The associated feature is either toggled on or off for all users at the same time. This is usually used for calendar events, like festivals, sales, anniversaries, etc.

Using a Redis set containing all enabled features

As you may be able to tell, these are really easy to implement. We can just use the familiar Redis Set again, by creating a set containing all "enabled" features, for example:

SADD features:always-on featureA featureB featureC ...

And then to test if the feature is enabled, just run SISMEMBER featureX.

Using the SET command

Another way to store these flags in Redis is just by using the SET command:

SET flag:A 1

Using SET enables you to add an expiry to the flag, so if, for example, your sale lasts for three days, you can automatically expire this feature three days from when it is set. And since SET allows you to specify a value, you can store any metadata about the flag, like the discount percentage for the sale. With Redis, the possibilities are endless!

If the value of the flag does not matter, you can use the EXISTS command to check if the field is set, instead of unnecessarily getting the value of the field with the GET command.

Using it in the app

Let's add a banner announcement to the top of the website which can be toggled on/off by the developer, and which will be visible to all users. I have chosen to make the value of the key be the banner text (in HTML markup). Let's do the same thing as earlier, write a function to get the value of the flag in src/lib/redis.ts.

// file: src/lib/redis.ts
import Redis from "ioredis";

export const EDIT_FLAG_KEY = "flag:edit-users";
export const BANNER_FLAG_KEY = "flag:banner";

// ...

export async function getBanner() {
  const redis = getClient();
  return (await redis.get(BANNER_FLAG_KEY)) ?? null
}

Now to add the actual banner itself to the top of the page. First, create the banner component:

// file: src/components/Banner.tsx
import { getBanner } from "@/lib/redis"

export default async function Banner() {
  const text = await getBanner();
  if (!text) return null;
  return <header className="w-full bg-blue-500 dark:bg-blue-600 px-4 py-1 text-center text-white">
    <p dangerouslySetInnerHTML={{__html: text}} />
  </header>
}

And finally, put the banner to the top of the page:

// file: src/pages/layout.tsx
import type { Metadata } from "next";
import localFont from "next/font/local";
import "./globals.css";
import Navbar from "@/components/Navbar";
import Banner from "@/components/Banner";
// ...

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body
        className={`${geistSans.variable} ${geistMono.variable} antialiased`}
      >
        <Banner />  {/* NEW */}
        <Navbar />
        <div className="py-8 max-w-screen-md md:mx-auto mx-4">{children}</div>
      </body>
    </html>
  );
}

And there you have it! You can now update the content of this banner by setting a key in Redis, without having to re-deploy your applcation. This allows you to have announcements on top of your page, like you see on many company websites.

Another common usage of feature flags is implementing a feature that enables itself based on user metadata. This could be anything, like the location of the user, the user's gender, the user's email host, or any other data your app collects. This can be a great addition if your app already delivers regionalised content to your users, which is indeed what Twitter does, hence these features can be added at no extra cost, utilising the IP-to-location APIs/infrastructure you already have.

Detecting user location

A way to detect the user's location is by using an IP database or an IP API, whichever makes sense based on your requirements. There are many services out there which sell IP Range to Country/Region/City databases, with accuracy and precision varying over payment tiers.

One such service I found online is ip2location. They provide databases which map IP ranges to countries, regions, cities, latitudes and longitudes, zipcodes, timezones, and whatever else you may need. This specific service also has a NodeJS SDK which you can use if you like.

I cannot speak for the accuracy of this service, but it surely is easily accessible. If your application requires higher accuracy, you should consider a paid database from a reputable vendor.

Here's a snapshot of the data they provide in CSV, from the file IP2LOCATION-LITE-DB1.csv:

"0","16777215","-","-"
"16777216","16777471","AU","Australia"
"16777472","16778239","CN","China"
"16778240","16779263","AU","Australia"
"16779264","16781311","CN","China"
"16781312","16785407","JP","Japan"
...

You should also download the IPv6 version of the database, since users can also connect with IPv6 IPs.

Import the ip location data

Since ip2location provides the data in CSV, you can easily import this into Postgres.

I'm only going to import the first three columns, that is, the IP that the range starts at, the IP that the range ends at, and the 2-letter ISO code for the country. Hence, I'll need to remove the last column from the CSV file by running the below command:

sed -E 's/,"[^"]+".$//' IP2LOCATION-LITE-DB1.csv > ipdb.csv
sed -E 's/,"[^"]+".$//' IP2LOCATION-LITE-DB1.IPV6.csv > ipv6db.csv

# copy the file to the docker container
docker cp ./ipdb.csv postgres:/ipdb.csv
docker cp ./ipv6db.csv postgres:/ipv6db.csv

Then, run the SQL commands to create a table to store the data, and to copy over the data from the CSV file in psql:

CREATE TABLE ip_countries (
  ip_from BIGINT  NOT NULL,
  ip_to   BIGINT  NOT NULL,
  country CHAR(2) NOT NULL,  -- country code
  PRIMARY KEY (ip_from, ip_to, country)
);

CREATE TABLE ipv6_countries (
  -- NUMERIC(39, 0) is a 39 digit *unsigned* decimal with 0 places after the decimal point (basically 39-digit non-negative integer)
	ip_from NUMERIC(39, 0) NOT NULL,
  ip_to   NUMERIC(39, 0) NOT NULL,
  country CHAR(2)        NOT NULL,  -- country code
  PRIMARY KEY (ip_from, ip_to, country)
);

COPY ip_countries (ip_from, ip_to, country) FROM '/ipdb.csv' FORMAT csv DELIMITER ',' HEADER false QUOTE '"';
COPY ipv6_countries (ip_from, ip_to, country) FROM '/ipv6db.csv' FORMAT csv DELIMITER ',' HEADER false QUOTE '"';

We need to use NUMERIC(39, 0), since the largest IPv6 address (when converted to a number) is a 128-bit number (2^128-1), which is 39 digits long.

Another thing to note is that the COPY command takes in a path local to the filesystem, so if you're using psql through docker, you'll need to docker cp the CSV file into the container first.

As of the time of writing, the CSV file provided by ip2location has no header row, has its fields separated by commas, and has quotes surrounding each value. All of these things been accounted for in the COPY command above, including the order of the columns in the CSV. If there is a change from this pattern when you download the file, you need to change the COPY command according to the format of the new data, for which you can use the docs provided by Postgres.

Tips for using ip2location in production

Don't forget to add an attribution to ip2location, since the terms of their free database require it. You'll also need to re-download the database and update it in Postgres every so often, since IP ranges change once in a while. And you'll also need to add indexes to these columns in Postgres to speed up IP range queries. I'd also suggest caching the user's country using the user's IP as the key in Redis, to improve speed even further if you'd wish. You can use Redis hashes to do this.

Store a list of country codes

You can store a list of country codes a feature is toggled on in using the familiar Redis Set. An example would be:

SADD "countries:flag-name" "DE" "FR" "US"

Add functions to get users IP

Now, all you need to do is to get the user's IP, get their country from the database, and to toggle on the desired features for the user.

To get the IP address of the user, you can use the X-Real-IP header automatically set by Railway when the app receives a request. Next, you'll need to convert the IP into a decimal number, and then find the country from the database.

// file: src/lib/ip.ts
import { headers } from "next/headers";
​
export function getClientIp(): string | null {
  return headers().get("x-real-ip");
}
​
export function convertIPv4ToNum(ip: string): number {
  const arr = ip.split(".");
  const len = arr.length;
  return arr.map((val, i) => parseInt(val)*(Math.pow(256, len-i-1))).reduce((a, b) => b + a)
}

That was just for IPv4 addresses! You also have to deal with IPv6 addresses. You must convert the user's IPv6 address to a BigInt, since JS numbers cannot express 128-bit numbers:

// file: src/lib/ip.ts
// ...
function expandIPv6(ip: string) {
  const parts = ip.split(':');
  const shortHandIndex = parts.indexOf('');
​
  if (shortHandIndex !== -1)  // expand shorthand IPv6 address
    parts.splice(shortHandIndex, 1, ...Array(8 - parts.length - 1).fill('0000'));

  // Pad each part to ensure it's 4 digits long
  return parts.map(part => part.padStart(4, '0')).join(':');
}
​
export function convertIPv6ToNum(ip: string) {
  const parts = expandIPv6(ip).split(":");
​
  let num = BigInt(0);
  for (let i = 0; i < 8; i++) {
    num = (num << BigInt(16)) | BigInt(parseInt(parts[i], 16));
  }
​
  return num;
}
​
export async function getClientCountry(ip: string): Promise<string | null> {
  if (ip.includes(':')) {  // IPv6
    const num = convertIPv6ToNum(ip);
    const { country } = (await sql`SELECT country FROM ipv6_countries WHERE ${num.toString()} BETWEEN ip_from AND ip_to`)[0] ?? {country: "-"};
    return country?.trim() === '-' ? null : country?.trim() ?? null;
  } else {
    const num = convertIPv4ToNum(ip);
    const { country } = (await sql`SELECT country FROM ip_countries WHERE ${num} BETWEEN ip_from AND ip_to`)[0] ?? {country: "-"};
    return country?.trim() === '-' ? null : country?.trim() ?? null;
  }
}

You can now use these functions in your application to create region-based features.

Add a new feature behind a flag

Let's utilise this by adding another banner to display "Happy Diwali" if the user is from India, Nepal, or Sri Lanka, and "Happy Halloween" otherwise. First, let's make this banner component:

// file: src/components/FestiveBanner.tsx
import { getClientCountry, getClientIp } from '@/lib/ip';
import { getClient } from '@/lib/redis';
import { cn } from '@/lib/util';
import React from 'react';

const FESTIVE_BANNER_FLAG = "flag:festive-banner";

export default async function FestiveBanner() {
  const redis = getClient();
  if ((await redis.exists(FESTIVE_BANNER_FLAG)) == 0) return null;

  const isDiwali = ["IN", "NP", "LK"].includes((await getClientCountry(getClientIp() ?? "::1")) ?? "");

  return (
    <div className={cn("px-4 py-2 text-center shadow-lg", isDiwali ? 'bg-gradient-to-r from-yellow-300 to-orange-600 text-black' : 'bg-gradient-to-r from-orange-400 to-red-700 text-white')}>
      <h1 className="text-xl font-bold">{isDiwali ? '🎆 Happy Diwali! 🪔' : '👻 Happy Halloween! 🎃'}</h1>
    </div>
  );
}

And then add it to the top of the page:

// file: src/pages/layout.tsx
// ...
import FestiveBanner from "@/components/FestiveBanner";

// ...

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body
        className={`${geistSans.variable} ${geistMono.variable} antialiased`}
      >
        <FestiveBanner />  {/* NEW */}
        <Banner />
        <Navbar />
        <div className="py-8 max-w-screen-md md:mx-auto mx-4">{children}</div>
      </body>
    </html>
  );
}

And there you go! If the flag:festive-banner key is SET in Redis to any value, then the banner should appear. But this banner always shows "Happy Halloween" in local development, since we're getting the user's IP through a header which Railway sets for us, and that header is not set in the local Next.js development server.

Let's deploy this app to Railway and see if it works as expected.

Add tables to Postgres in Railway

Before deployment, you need to add the IP-to-country tables to the Postgres database on Railway. To do this, we'll copy the data in the local ip_countries and ipv6_countries tables to the database on Railway.

First, dump the data into two files by running these commands:

pg_dump -h localhost -U postgres -d postgres -t ip_countries --data-only --format=custom -f ipv4.dump
pg_dump -h localhost -U postgres -d postgres -t ipv6_countries --data-only --format=custom -f ipv6.dump

If you're using docker to run the postgres server locally, you can run these commands in a bash session within the container, which can be started using: docker exec -it postgres bash.

Next, connect to the postgres instance on Railway by copying the connection command displayed when clicking the Connect button in the Postgres service's Data tab, as shown earlier in Step 2.

PGPASSWORD=... psql -h ABC.proxy.rlwy.net -U postgres -p ... -d railway

Then, run the SQL commands to create the ip_countries and ipv6_countries tables as you did earlier.

Exit from the psql session, and use pg_restore to copy the data from the dumped file to the production database:

PGPASSWORD=... pg_restore -h ABC.proxy.rlwy.net -U postgres -p ... -d railway -t ip_countries -f ipv4.dump
PGPASSWORD=... pg_restore -h ABC.proxy.rlwy.net -U postgres -p ... -d railway -t ipv6_countries -f ipv6.dump

And the data has been copied!

Deploy app changes to Railway

Now, we can deploy the app to Railway by running railway up.

Test the feature flags

Now you can try changing the feature flags you just added by connecting to the Redis instance by copying the connect command from the Railway dashboard for the Redis service, similar to what you did for connecting to Postgres. The command should look like this:

redis-cli -u redis://default:SOME_PASSWORD@SOME_PROXY.proxy.rlwy.net:SOME_PORT

If you want to randomize the users who can edit, you can run the script, passing the production database connection URL and the Redis URL as environment variables:

DATABASE_URL=postgres://SOME_USER:SOME_PASSWORD@SOME_PROXY.proxy.rlwy.net:SOME_PORT/SOME_DB REDIS_URL=redis://default:SOME_PASSWORD@SOME_PROXY.proxy.rlwy.net:SOME_PORT pnpx tsx src/lib/scripts/randomizeEditUsers.ts

In this article, you've learned how to add feature flags to an application from scratch, without using a third-party provider, thus avoiding vendor-locking and extra unnecessary costs.

There are quite a few other things you can do, such as add an admin panel for other developers to change the flags without having to do it through redis-cli, cache long-lived feature flags locally to increase speed, add an analytics platform like Plausible or Matomo to understand how users interact with features, and of course, utilise what you've learnt here and add these flags to your own application and deploy it on Railway!

The source code for this project is available on GitHub here. The master branch contains the starter code that you were made to clone earlier. The completed branch contains the source code after implementing everything shown in this article.