Dev.to WebDev πŸ›  Dev πŸ‘ 0 πŸ“– 6 min read

Separate release from deploy: feature flags in Next.js without a vendor

Most teams ship a feature the moment it's merged. The deploy and the release are the same event, so every deploy is a small bet: if the new checkout has a bug, every user finds it at once, and the fix is another deploy u

Most teams ship a feature the moment it's merged. The deploy and the release are the same event, so every deploy is a small bet: if the new checkout has a bug, every user finds it at once, and the fix is another deploy under pressure.

Feature flags split the two. You deploy the code switched off, then release it to 1%, 10%, 50% of users while you watch the numbers, and switch it off again in seconds if something breaks. Flag services do this very well, and for large teams they're worth paying for. But for a lot of Next.js apps, the useful 80% fits in one file you own.

Here's a starting point: deterministic percentage rollouts, an allowlist, a kill switch, QA overrides, and a test that stops flags from living forever. All of it is tested, and the output is below.

What a flag needs to do

  1. Off by default for most users, on for some. A percentage rollout, plus an allowlist for your team and beta customers.
  2. Stable per user. The same user gets the same answer on every request and every server, or they'll see the old and new checkout alternate.
  3. Monotonic. Users who had the feature at 10% still have it at 50%. Raising the rollout should only add people.
  4. A kill switch that turns it off for everyone.
  5. An expiry date. A flag that never gets removed becomes a permanent if that nobody dares delete.

The whole thing

// lib/flags.ts
import { createHash } from "node:crypto";

type Flag = {
  enabled: boolean;   // kill switch: false turns it off for everyone
  rollout?: number;   // 0-100: share of users who get it
  allow?: string[];   // user IDs who always get it (team, beta customers)
  expires?: string;   // ISO date: when this flag should be gone from the code
};

const defaults = {
  newCheckout: { enabled: true, rollout: 10, allow: ["user_team_1"], expires: "2026-12-31" },
  searchV2: { enabled: false, expires: "2027-03-31" },
} satisfies Record<string, Flag>;

export type FlagName = keyof typeof defaults;

// Per-environment overrides without a code change, e.g.
// FLAGS='{"newCheckout":{"rollout":50}}'
function config(): Record<FlagName, Flag> {
  const overrides = JSON.parse(process.env.FLAGS ?? "{}") as Partial<Record<FlagName, Partial<Flag>>>;
  const merged = { ...defaults } as Record<FlagName, Flag>;
  for (const name of Object.keys(overrides) as FlagName[]) {
    if (name in merged) merged[name] = { ...merged[name], ...overrides[name] };
  }
  return merged;
}

// Same user + same flag = same bucket, on every server, every request.
export function bucket(flag: string, userId: string): number {
  const hash = createHash("sha256").update(`${flag}:${userId}`).digest();
  return hash.readUInt32BE(0) % 100; // 0-99
}

export function isEnabled(
  flag: FlagName,
  ctx: { userId?: string; overrides?: Partial<Record<FlagName, boolean>> } = {},
): boolean {
  const f = config()[flag];
  if (ctx.overrides?.[flag] !== undefined) return ctx.overrides[flag]!; // QA override
  if (!f.enabled) return false;
  if (ctx.userId && f.allow?.includes(ctx.userId)) return true;
  if (f.rollout === undefined) return true;
  if (!ctx.userId) return false; // anonymous users wait for 100%
  return bucket(flag, ctx.userId) < f.rollout;
}

// Flags are temporary. This keeps them that way.
export function expiredFlags(now = new Date()): FlagName[] {
  return (Object.keys(defaults) as FlagName[]).filter((name) => {
    const expires = (defaults[name] as Flag).expires;
    return expires !== undefined && new Date(expires) < now;
  });
}

// QA can force a flag with a cookie, but never in production for regular users.
export function readOverrides(
  raw: string | undefined,
  allowed: boolean,
): Partial<Record<FlagName, boolean>> | undefined {
  if (!allowed || !raw) return undefined;
  try {
    return JSON.parse(raw) as Partial<Record<FlagName, boolean>>;
  } catch {
    return undefined;
  }
}

The key line is bucket(). Hashing flag:userId with SHA-256 and taking the result modulo 100 puts every user in a stable bucket from 0 to 99, per flag. A user in bucket 7 gets the feature at any rollout above 7, so raising the percentage only ever adds users. Including the flag name in the hash means a different 10% of users try each feature, so the same unlucky people don't get every experiment.

Anonymous users get nothing until the rollout reaches 100%. Without a stable ID there's no way to keep their experience consistent, and a flickering checkout is worse than an old one.

Does it behave?

We ran it against 100,000 generated user IDs:

10% rollout -> 9.89%
deterministic: true
allowlist: true
killed flag: false
QA override: true
anonymous: false
env override 50% -> 50.15%
monotonic rollout: true
kill switch: false
expired now: []
expired 2027-01-15: [ 'newCheckout' ]
overrides blocked: undefined
overrides allowed: { searchV2: true }
bad cookie: undefined

The split is close to the target, the same user always lands in the same place, everyone in the 10% is still in at 50%, and setting enabled: false beats the allowlist.

Using it in the App Router

Evaluate flags on the server and send down only the result. The client never sees the flag config, other users' buckets or the rollout percentages, and there's no flash of the old UI while a client-side SDK loads.

// app/checkout/page.tsx (Server Component)
import { cookies } from "next/headers";
import { isEnabled, readOverrides } from "@/lib/flags";
import { getUser } from "@/lib/auth";
import { NewCheckout } from "./new-checkout";
import { OldCheckout } from "./old-checkout";

export default async function CheckoutPage() {
  const user = await getUser();
  const cookieStore = await cookies();
  const overrides = readOverrides(
    cookieStore.get("flag_overrides")?.value,
    process.env.NODE_ENV !== "production" || user?.role === "staff",
  );

  const newCheckout = isEnabled("newCheckout", { userId: user?.id, overrides });
  return newCheckout ? <NewCheckout /> : <OldCheckout />;
}

cookies() is async from Next.js 15 on, hence the await. If a client component needs to know about a flag, pass the boolean down as a prop from the server component rather than evaluating it in the browser.

The QA override lets testers force a flag on with a flag_overrides cookie such as {"searchV2":true}. It's ignored in production unless the user is staff, so customers can't turn on unfinished features by editing a cookie. Overrides deliberately win over the kill switch: QA needs to test a feature before anyone else can see it.

Rolling out

The FLAGS environment variable overrides the defaults per environment, without changing code:

FLAGS='{"newCheckout":{"rollout":50}}'
FLAGS='{"newCheckout":{"enabled":false}}'   # kill switch

Be honest with yourself about one limitation: on most hosts, changing an environment variable means a redeploy or a restart, which takes minutes, not seconds. If you need an instant kill switch, keep the same Flag shape but load the overrides from your database or a key-value store with a short cache (30 to 60 seconds) instead of process.env. The rest of the code doesn't change.

A rollout that works for most features:

  1. Allowlist only: your team uses it in production for a few days.
  2. 5 to 10%: compare errors, latency and the one metric the feature is meant to move against users without it.
  3. 50%, then 100%.
  4. Delete the flag and the old code path.

That last step is the one that gets skipped.

Make flags expire

Every flag has an expires date, and one test fails the build once a flag outlives it:

// lib/flags.test.ts
import { test } from "node:test";
import assert from "node:assert/strict";
import { expiredFlags } from "./flags";

test("no feature flag has outlived its expiry date", () => {
  assert.deepEqual(expiredFlags(), [], "Remove these flags and the old code paths");
});

It's a small thing that changes behaviour. When the test fails, somebody has to either delete the flag or consciously push the date out. Both are better than a codebase with forty forgotten if (isEnabled(...)) branches.

When to buy instead

Write your own while flags are a release tool for one team. Buy a service when you need any of these: non-developers changing targeting in a UI, an audit log of who changed what, experiments with proper statistics, or targeting rules beyond user ID and percentage. At that point the file above has done its job, and its isEnabled(flag, ctx) signature makes the switch a small change.

Release practices like this are part of how we approach web development and SaaS development. They're worth setting up before the first big feature ships, because retrofitting them later means touching every risky code path twice.

How do you release risky features today: flags, a staging environment, or courage? We'd like to hear what's worked for you, especially on small teams.

From the team at Redlio Labs, where we build and run web applications and SaaS products.

πŸ“° Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.