DevStacked
ArchitectureAugust 3, 202623 min read

Feature Flags in SaaS: A Beginner's Guide with Next.js 16 (2026)

You just shipped a new billing page. It works perfectly in your local environment, QA gives it a thumbs up, and you deploy on a Friday afternoon (bold choice). Twenty minutes later, support tickets start rolling in — the new checkout flow is broken for a chunk of your users, and the only fix is a full redeploy, which means more downtime while you scramble.

Here's the thing: that entire scenario is avoidable. Not by testing harder, but by changing how you ship. If that billing page had been behind a feature flag, you could've turned it off instantly — no redeploy, no rollback, no 2am panic. Just a toggle switch.

By the end of this guide, you'll understand exactly what feature flags are, why almost every real SaaS product uses them, and you'll have a working feature flag system built into a Next.js 16 app — from a simple database-backed version you can build yourself, to Vercel's official Flags SDK for a more polished setup.


Why This Is Tricky

Feature flags sound simple — "just an if statement," right? And technically, that's true. But most beginners either overuse them (turning every branch of code into a flag, creating a tangled mess) or underuse them (skipping flags entirely and relying only on git push to control what users see).

The real skill isn't writing an if statement — it's answering three questions correctly:

  1. Where should the flag be checked? Client, server, or both?
  2. Where should the flag's value live? Hardcoded, a database, or a third-party service?
  3. What happens when the flag is wrong or missing? Does your app break, or fail safely?

We'll answer all three as we build this out.


What Is a Feature Flag, Exactly?

A feature flag (also called a feature toggle) is a switch in your code that controls whether a piece of functionality is active — without needing to change or redeploy your code to flip it.

💡 Think of it like: a light switch wired into your wall during construction, instead of soldering a lamp directly to the power line. The wiring (your code) ships once. Whether the light is on or off is a decision you can change any time, without touching the wall again.

In practice, a feature flag is just a boolean (or sometimes a string/number) that your code checks before rendering something or running some logic:

if (isNewCheckoutEnabled) {
  return <NewCheckout />;
}

return <OldCheckout />;

The interesting part isn't that if statement — it's where isNewCheckoutEnabled comes from and how you control it. That's what the rest of this guide covers.


Why SaaS Products Rely on Feature Flags

A handful of concrete reasons feature flags matter, especially for SaaS:

  • Ship without releasing. You can merge and deploy unfinished code to production behind a flag that's off. This is called "trunk-based development" — your main branch always reflects what's deployed, but not everything deployed is visible yet.
  • Instant kill switch. If something breaks, flip the flag off. No git revert, no redeploy, no waiting for a build to finish while your app is broken.
  • Gradual rollouts. Release a risky feature to 5% of users first, watch for errors, then dial it up to 25%, 50%, 100% — instead of an all-or-nothing launch.
  • Paid plan gating. Show a feature only to customers on your Pro or Enterprise plan, without maintaining separate codebases per plan.
  • A/B testing. Show two variants of a feature to different user segments and measure which one performs better.
  • Beta programs. Let specific users (or your own team) try a feature early, without exposing it to everyone.

💡 Tip: These use cases usually map to four flag types: release flags (hide unfinished work), ops flags (kill switches), permission flags (plan-based access), and experiment flags (A/B tests). Naming your flags after their type helps keep a growing flag list organized — more on this in the best-practices section.


Prerequisites

This guide uses:

  • Next.js 16 with the App Router
  • TypeScript in strict mode
  • Supabase as the database (the same pattern works with any Postgres/SQL database — just swap the client)
  • Tailwind CSS for the tiny UI examples

We'll build two versions of the same thing:

  1. A do-it-yourself, database-backed flag system — no extra service, full control, great for learning how flags actually work under the hood.
  2. The Vercel Flags SDK — an official, open-source toolkit purpose-built for Next.js that adds typed flags, a local override toolbar, and a clean pattern for swapping providers later.

You don't need both — pick whichever fits your project, or read both to understand the tradeoffs.

  User Request
      
      
Server Component
      
      
getFeatureFlag()
      
      
Next.js Cache
      
      ├── Cache Hit ─────► true / false
      
      
  Supabase
      
      
 true / false
      
      
  Render UI

Part 1: Building Your Own Feature Flag System

This is the version to reach for if you want zero extra dependencies and full control over where flag data lives — a great fit if you're already using Supabase for your app's database.

Step 1: Create the Feature Flags Table

Every flag needs a key (a unique name), whether it's enabled, and optionally a rollout percentage for gradual releases.

-- Run this in the Supabase SQL editor
create table public.feature_flags (
  key text primary key,
  enabled boolean not null default false,
  rollout_percentage int not null default 100
  check (rollout_percentage between 0 and 100),
  description text,
  updated_at timestamptz not null default now()
);

-- Keep RLS on with no policies  only a service-role client can read this,
-- not even your normal server-side (cookie-based) Supabase client, and never the anon key.
alter table public.feature_flags enable row level security;

What's happening here: key is a plain text primary key like "new-checkout" — your code will reference flags by this string. enabled is the master on/off switch. rollout_percentage lets you release to a slice of users instead of everyone at once (100 means "everyone who has the flag enabled sees it," 25 means "only about a quarter of them do"). Enabling Row Level Security with no policies means this table isn't readable through the public API at all — only your server-side Supabase client (which uses full database access) can read it, which is exactly what you want for something that controls app behavior.

⚠️ Common Mistake: Leaving this table world-readable through the anon key. Even though flag values aren't usually "secret" in the sensitive-data sense, exposing your entire flag list (including unreleased feature names) to anyone poking at your API is an easy, avoidable leak.

Add a couple of test rows:

insert into public.feature_flags (key, enabled, rollout_percentage, description)
values
  ('new-checkout', true, 100, 'Redesigned checkout flow'),
  ('ai-summary', true, 20, 'AI-generated summary panel (early rollout)');

Step 2: Write a Deterministic Rollout Function

If rollout_percentage is 20, you want the same 20% of users to see the feature every time they visit — not a random 20% on every page load. That requires a deterministic check: given the same user and the same flag, always return the same answer.

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

/**
 * Deterministically decides whether a given user falls inside a flag's
 * rollout percentage. The same userId + flagKey combination always
 * produces the same result, so a user doesn't flicker between the old
 * and new experience on every request.
 */
export function isInRollout(
  userId: string,
  flagKey: string,
  percentage: number
): boolean {
  if (percentage >= 100) return true;
  if (percentage <= 0) return false;

  const hash = createHash("sha256")
    .update(`${flagKey}:${userId}`)
    .digest("hex");

  // Take the first 8 hex characters, turn them into a number,
  // and reduce it to a bucket between 0 and 99.
  const bucket = parseInt(hash.slice(0, 8), 16) % 100;

  return bucket < percentage;
}

What's happening here: createHash("sha256") hashes the combination of the flag key and the user's ID. Because hashing is deterministic (the same input always produces the same output), the same user always lands in the same "bucket" (a number from 0–99) for a given flag. If that bucket number is less than the rollout percentage, they see the feature. Since we mix in the flag key too, the same user can be in the "in" bucket for one flag and the "out" bucket for a completely different flag — buckets aren't shared across flags.

Step 3: Create a Server-Only Function to Read a Flag

First create a service-role Supabase client

The feature_flags table has RLS enabled with no policies, so it can't be read by any client except one authenticated with your service role key — not even your normal server-side (cookie-based) Supabase client can see it. That's actually a good fit here, since flags are app-level configuration, not user data, and it also happens to be the client we need for caching: it has no cookies or per-request session attached, which Next.js requires for anything wrapped in "use cache".

// lib/supabase/service-client.ts
import { createClient } from "@supabase/supabase-js";

// Service-role client — bypasses RLS, no cookies/session attached.
// Only ever use this for trusted, server-only reads like feature flags.
// Never import it into a Client Component, and never prefix the key with NEXT_PUBLIC_.
export const supabaseAdmin = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
);

⚠️ Common Mistake: Leaking the service role key to the browser. It must stay a plain (non-NEXT_PUBLIC_) environment variable, set only in your server environment — this key bypasses every RLS policy in your database, so treat it with the same care as a database password.

// lib/flags/get-flag.ts
import { supabaseAdmin } from "@/lib/supabase/service-client";
import { isInRollout } from "./rollout";

/**
 * Reads a feature flag's value for a given user.
 * Server-only — never call this from a Client Component.
 */
export async function getFeatureFlag(
  key: string,
  userId?: string
): Promise<boolean> {
  const { data, error } = await supabaseAdmin
    .from("feature_flags")
    .select("enabled, rollout_percentage")
    .eq("key", key)
    .maybeSingle();

  // Fail safe: an unknown or errored flag defaults to "off",
  // not "on" — a missing flag should never accidentally expose
  // an unfinished feature to everyone.
  if (error || !data || !data.enabled) {
    return false;
  }

  // No user context (e.g. a public page) — just use the master switch.
  if (!userId) {
    return data.rollout_percentage >= 100;
  }

  return isInRollout(userId, key, data.rollout_percentage);
}

What's happening here: this function is the single place that knows how to read a flag — every page, Server Action, or Route Handler in your app calls through here instead of querying Supabase directly. Notice the fail-safe default: if the flag doesn't exist, or the database call errors out, we return false. That's a deliberate choice — a flag failing "closed" (off) is almost always safer than failing "open" (on), since an unexpected true could expose an unfinished feature to every user.

Caching Feature Flag Reads with next/cache

Right now, getFeatureFlag() hits Supabase on every single call. If a flag is checked on a high-traffic page — or several times per request, once per feature — that's a lot of avoidable database round trips for data that barely changes. A hand-rolled in-memory cache can fix that, but it comes with real downsides in production: it only lives inside one serverless function instance (so different instances can disagree), and it disappears completely on every cold start or redeploy. Next.js already ships a caching layer built for exactly this — reach for that instead of reinventing it.

The good news: the shared part of a flag (its enabled state and rollout_percentage) is identical for every user. Only the final true/false decision is per-user, and that part is just cheap in-memory hashing. So you only need to cache the database row, not the per-user result.

Caching the flag row

// lib/flags/get-flag.ts
import { cacheLife, cacheTag } from "next/cache";
import { supabaseAdmin } from "@/lib/supabase/service-client";
import { isInRollout } from "./rollout";

type FlagRow = { enabled: boolean; rollout_percentage: number };

async function fetchFlagRow(key: string): Promise<FlagRow | null> {
  "use cache";
  cacheLife("minutes"); // safety-net expiry if nothing ever invalidates it
  cacheTag(`feature-flag:${key}`); // lets us invalidate just this one flag

  const { data, error } = await supabaseAdmin
    .from("feature_flags")
    .select("enabled, rollout_percentage")
    .eq("key", key)
    .maybeSingle();

  return error ? null : data;
}

export async function getFeatureFlag(
  key: string,
  userId?: string
): Promise<boolean> {
  const row = await fetchFlagRow(key);

  if (!row || !row.enabled) return false;
  if (!userId) return row.rollout_percentage >= 100;

  return isInRollout(userId, key, row.rollout_percentage);
}

What's happening here: "use cache" at the top of fetchFlagRow marks that function as a cache boundary — Next.js automatically derives the cache key from the function's arguments (key), so each flag gets its own independent cache entry with no manual key-building required. cacheLife("minutes") applies one of Next.js's built-in expiry profiles as a fallback expiry (a handful of minutes), in case nothing ever explicitly invalidates it. cacheTag(\feature-flag:$`)` is the important part for production use — it lets you invalidate exactly one flag on demand instead of waiting for the fallback expiry or flushing every cached flag at once.

💡 Tip: "use cache" requires Cache Components to be enabled in next.config.ts:

const nextConfig: NextConfig = {
  cacheComponents: true,
};

If your project isn't on Cache Components yet, use the older unstable_cache API instead — same tagging idea, just a wrapper function instead of a directive:

import { unstable_cache } from "next/cache";

const fetchFlagRow = (key: string) =>
  unstable_cache(
    async () => {
      const { data, error } = await supabaseAdmin
        .from("feature_flags")
        .select("enabled, rollout_percentage")
        .eq("key", key)
        .maybeSingle();
      return error ? null : data;
    },
    ["feature-flag", key],
    { tags: [`feature-flag:${key}`], revalidate: 60 }
  )();

Invalidating a flag the moment it changes

Caching only pays off if flipping a flag actually takes effect quickly. Instead of waiting out cacheLife's fallback window, invalidate the specific tag the moment an admin updates the row:

// actions/toggle-feature-flag.ts
"use server";

import { revalidateTag } from "next/cache";
import { supabaseAdmin } from "@/lib/supabase/service-client";

export async function toggleFeatureFlag(key: string, enabled: boolean) {
  const { error } = await supabaseAdmin
    .from("feature_flags")
    .update({ enabled, updated_at: new Date().toISOString() })
    .eq("key", key);

  if (error) {
    return { error: error.message };
  }

  // Instantly invalidates only this flag's cached row — every
  // subsequent getFeatureFlag() call re-fetches fresh data.
  revalidateTag(`feature-flag:${key}`);

  return { success: true };
}

What's happening here: revalidateTag(\feature-flag:$`)tells Next.js's cache "anything tagged with this string is now stale" — the very next call tofetchFlagRow(key)skips the cache and re-fetches from Supabase, then caches the fresh result under the same tag. This is what makes a cached flag system still behave like an instant kill switch: the cache saves you database round trips on the read path, whilerevalidateTag` guarantees a write is reflected immediately, not after some fixed delay.

⚠️ Common Mistake: Updating the feature_flags table directly from the Supabase dashboard and expecting the change to show up right away. The cache has no way to know the row changed unless something calls revalidateTag() — always flip flags through a path (like the Server Action above) that revalidates the tag, or expect to wait out the cacheLife window.

Step 4: Use the Flag in a Server Component

// app/dashboard/page.tsx
import { getFeatureFlag } from "@/lib/flags/get-flag";
import { getCurrentUser } from "@/lib/auth"; // whatever your auth setup returns
import { NewDashboard } from "@/components/dashboard/new-dashboard";
import { OldDashboard } from "@/components/dashboard/old-dashboard";

export default async function DashboardPage() {
  const user = await getCurrentUser();
  const showNewDashboard = await getFeatureFlag("new-dashboard", user?.id);

  return showNewDashboard ? <NewDashboard /> : <OldDashboard />;
}

What's happening here: because DashboardPage is an async Server Component, we can await getFeatureFlag(...) directly during render — no loading spinner, no client-side fetch, no flicker between the two versions. The flag is resolved once, on the server, before any HTML reaches the browser.

Step 5: Use the Same Flag in a Server Action

Flags aren't just for showing/hiding UI — sometimes you want to gate a whole action, like a new AI feature that costs you money per call.

// actions/generate-summary.ts
"use server";

import { getFeatureFlag } from "@/lib/flags/get-flag";
import { getCurrentUser } from "@/lib/auth";

export async function generateSummary(text: string) {
  const user = await getCurrentUser();

  if (!user) {
    return { error: "You must be signed in." };
  }

  const enabled = await getFeatureFlag("ai-summary", user.id);

  if (!enabled) {
    return { error: "This feature isn't available yet." };
  }

  // ... call your AI provider here
  return { summary: "..." };
}

What's happening here: re-checking the flag inside the Server Action matters even though the button that triggers it is already hidden by the flag in the UI. A hidden button doesn't stop someone from calling the Server Action directly — the server-side check is your real gate, the UI check is just for user experience.

⚠️ Common Mistake: Only checking a flag in the UI (hiding a button) and assuming that's enough. Server Actions are callable endpoints under the hood — always re-verify anything sensitive on the server, the same way you would with authentication or authorization checks.

Related: Role-Based Access Control (RBAC) in Next.js 16 (2026 Beginner's Guide)


Part 2: Using the Vercel Flags SDK

The do-it-yourself version above works fine, but as your flag list grows you'll want a few things it doesn't give you: a typed, centralized list of every flag in your app, a way to preview flag values locally without touching the database, and a consistent pattern if you ever want to swap in a dedicated flag provider later.

That's exactly what the Flags SDK (built by the Vercel/Next.js team) solves. It's free, open source, framework-native for Next.js, and — importantly — it works with any data source, including the Supabase table you just built. You're not locked into a specific vendor.

Step 1: Install the SDK

npm install flags

Step 2: Generate a Flags Secret

The SDK uses a secret key to encrypt flag values when they're referenced by its local override tooling. Generate one with Node:

node -e "console.log(crypto.randomBytes(32).toString('base64url'))"

Copy the output into your environment variables:

# .env.local
FLAGS_SECRET=your-generated-secret-here

💡 Tip: Use a different FLAGS_SECRET value for each environment (development, preview, production). If you're deploying on Vercel, set each one with vercel env add FLAGS_SECRET production --sensitive.

Step 3: Define Your Flags in One File

This is the part that makes the SDK worth using — every flag in your app is declared once, as a typed function, in a single file.

// flags.ts
import { flag } from "flags/next";
import { getFeatureFlag } from "@/lib/flags/get-flag";
import { getCurrentUser } from "@/lib/auth";

export const newDashboardFlag = flag<boolean>({
  key: "new-dashboard",
  description: "Show the redesigned dashboard layout",
  decide: async () => {
    const user = await getCurrentUser();
    return getFeatureFlag("new-dashboard", user?.id);
  },
});

export const aiSummaryFlag = flag<boolean>({
  key: "ai-summary",
  description: "AI-generated summary panel (early rollout)",
  decide: async () => {
    const user = await getCurrentUser();
    return getFeatureFlag("ai-summary", user?.id);
  },
});

What's happening here: flag() wraps each feature into its own reusable, typed function. Notice the decide function is just calling the exact same getFeatureFlag() helper you already built in Part 1 — the SDK doesn't replace your data source, it sits on top of it. This means you get the SDK's developer experience (typed flags, the local toolbar, easy provider swaps later) while still owning your data in Supabase.

💡 Tip: Turning every flag into its own exported function means your editor's "Find All References" feature works — you can instantly see every place a flag is used, and confidently delete it once it's no longer needed.

Step 4: Use a Flag in a Page

// app/dashboard/page.tsx
import { newDashboardFlag } from "@/flags";
import { NewDashboard } from "@/components/dashboard/new-dashboard";
import { OldDashboard } from "@/components/dashboard/old-dashboard";

export default async function DashboardPage() {
  const showNewDashboard = await newDashboardFlag();

  return showNewDashboard ? <NewDashboard /> : <OldDashboard />;
}

What's happening here: calling newDashboardFlag() runs the decide function you defined in flags.ts and returns the resolved boolean. Notice there are no arguments at the call site — the flag function already knows how to figure out the current user internally. This is intentional: it keeps every call site simple and makes flags easy to reason about, since the logic for how a flag is decided lives in exactly one place.

⚠️ Common Mistake: Trying to call a flag function from a Client Component. Flags SDK flags are server-only by design — this avoids client-side loading spinners and keeps the decision logic (which might touch a database or user session) off the browser entirely. If a Client Component needs the value, resolve it in the parent Server Component and pass it down as a prop.

Step 5: Conditionally Render a Client Component

// app/dashboard/page.tsx
import { aiSummaryFlag } from "@/flags";
import { SummaryPanel } from "@/components/dashboard/summary-panel";

export default async function DashboardPage() {
  const showAiSummary = await aiSummaryFlag();

  return (
    <div className="space-y-6">
      <h1 className="text-2xl font-semibold">Dashboard</h1>
      {showAiSummary && <SummaryPanel />}
    </div>
  );
}
// components/dashboard/summary-panel.tsx
"use client";

export function SummaryPanel() {
  // Interactive logic here — the flag decision already
  // happened on the server before this component ever rendered.
  return (
    <div className="rounded-xl border p-4">
      <p className="text-sm text-muted-foreground">AI summary coming soon…</p>
    </div>
  );
}

What's happening here: the Server Component resolves the flag once and only renders <SummaryPanel /> at all if the flag is on. The Client Component itself never needs to know a flag exists — it just renders when it's asked to. This keeps the flag-checking logic entirely on the server, which is both simpler and safer than shipping the flag's raw value to the browser.


Best Practices for Feature Flags

A handful of habits that keep a growing flag system from turning into a mess:

  • Name flags consistently. Use a predictable pattern like kebab-case with a type hint, e.g. release-new-checkout, experiment-pricing-copy, ops-disable-emails, permission-ai-summary. It makes a flag's purpose obvious at a glance.
  • Default to "off" everywhere. As shown in getFeatureFlag(), an unknown, missing, or errored flag should resolve to false. Fail closed, not open.
  • Delete flags once they've served their purpose. A release flag that's been at 100% for a month and never gets turned off isn't a flag anymore — it's just a permanent if statement adding complexity. Remove it and the dead code branch behind it.
  • Never gate real security on a flag alone. A feature flag controls visibility, not authorization. Someone technically could still reach a hidden feature by calling the underlying Server Action or API route directly — always pair a flag with a real permission check for anything sensitive (see Step 5 in Part 1).
  • Test both states. Before shipping, manually verify your app behaves correctly with the flag both on and off — it's easy to only test the "on" path and forget the fallback still needs to work.
  • Keep the rollout logic deterministic. As covered in Part 1, a user should see a consistent experience across requests, not a coin flip on every page load.
     Create
       
    Develop
       
Internal Testing
       
       5%
       
      25%
       
      50%
       
     100%
       
    Delete

Frequently Asked Questions

Do I need a third-party service like LaunchDarkly to use feature flags?

No. A single database table, like the feature_flags table built in Part 1, is enough for most SaaS apps — especially early on. Third-party feature flag platforms add real value once you need advanced targeting rules, detailed analytics per flag, or a non-technical team managing flags through a dashboard, but they're not a requirement to get started.

What's the difference between a feature flag and an environment variable?

An environment variable is set at build or deploy time and requires a redeploy to change. A feature flag is checked at runtime and can be flipped instantly without touching your deployment — that's the whole point. Use environment variables for configuration (API keys, database URLs) and feature flags for behavior that needs to change without a redeploy.

Can I use feature flags for A/B testing?

Yes — this is one of the most common uses. Instead of a simple on/off boolean, an experiment flag typically returns one of several variants (e.g. "control" or "variant-b"), and you track which variant a user saw alongside your analytics events to measure the difference in behavior.

Do feature flags have to be true/false?

No — boolean is just the simplest case. Many production flags are multivariate: instead of resolving to true/false, they resolve to one of several named values, like "control", "variant-a", or "variant-b". This is what powers most pricing, copy, and layout experiments, where you're comparing more than two options at once. The pattern in this guide extends naturally — swap the enabled boolean column for a value column (text or jsonb), and type your Flags SDK flag as flag<string>({...}) instead of flag<boolean>({...}) so decide() returns the variant name instead of a boolean. Everything else — the deterministic bucketing, the caching, the server-only checks — works exactly the same way.

Are feature flags secure enough to gate paid features?

A flag can decide whether to show a paid feature, but it should never be the only check. Always verify the user's actual subscription/permission status on the server (inside the Server Action or API route that does the real work) — the flag is a UX layer on top of that, not a replacement for it.

How many feature flags is too many?

There's no hard number, but if you regularly can't remember what a flag does or find yourself afraid to delete one, that's a sign your flag list needs cleanup. A good habit is reviewing flags monthly and removing any release flag that's been fully rolled out and stable for a few weeks.

Does checking a flag slow down my page?

If you're checking the same flags on every request, the Next.js caching approach from the Caching Feature Flag Reads with next/cache section removes most of that cost while still allowing immediate invalidation with revalidateTag().


Wrapping Up

Feature flags turn deployment and release into two separate decisions — you can ship code to production without exposing it, roll features out gradually, and flip a broken feature off in seconds instead of scrambling through a redeploy. You've now got two working approaches: a simple, fully self-owned system backed by a Supabase table, and the Vercel Flags SDK layered on top of it for a more polished, typed developer experience.

Start small — pick one risky upcoming feature, put it behind a flag using the Part 1 pattern, and get comfortable with the workflow before rolling flags out across your whole app.

Continue Learning

Feature FlagsNext.jsSaaSTypeScriptSupabaseApp Router
Share On