The Misconception Killing Your Deployment Strategy

Most small SaaS teams treat feature flags as a luxury — something you adopt once you can afford LaunchDarkly or Flagsmith Cloud. That assumption is costing you. Dark launches, gradual rollouts, and kill switches are not enterprise perks. They are basic risk management, and you can implement a production-ready version in an afternoon using tools you already have: Next.js, Postgres, and a handful of environment variables.

This guide builds a lightweight, self-hosted feature flag system from scratch. No third-party SDK. No monthly bill. Just clean, maintainable code your whole team can reason about.


What a Feature Flag System Actually Needs

Before writing a single line of code, be clear about the requirements. A minimal viable flag system needs to:

  • Store flag definitions — name, enabled state, and optional targeting rules
  • Evaluate flags at runtime — per-request or per-user, not just at build time
  • Be overridable locally — so developers can test gated features without touching the database
  • Fail safely — if the flag store is unreachable, the app should fall back to a default, not crash

That is it. You do not need real-time streaming, audit logs, or a drag-and-drop UI on day one.


Step 1: Create the Flags Table in Postgres

Start with a single, minimal table. If you are already using Postgres (via Supabase, Railway, Neon, or a self-hosted instance), add this migration:

CREATE TABLE feature_flags (
  id          SERIAL PRIMARY KEY,
  key         TEXT NOT NULL UNIQUE,
  enabled     BOOLEAN NOT NULL DEFAULT FALSE,
  rollout_pct INTEGER NOT NULL DEFAULT 100, -- 0–100, % of users who see this flag
  description TEXT,
  updated_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Seed a sample flag
INSERT INTO feature_flags (key, enabled, rollout_pct, description)
VALUES ('new_dashboard', FALSE, 0, 'Redesigned analytics dashboard — dark launch');

The rollout_pct column is what separates a basic toggle from a real deployment tool. Set it to 10 and only 10% of your users will enter the new code path — giving you a controlled canary release without any paid infrastructure.


Step 2: Build the Flag Evaluation Layer

Create a utility module at lib/flags.ts. This is the single source of truth for flag resolution across your entire Next.js app.

// lib/flags.ts
import { sql } from '@vercel/postgres'; // or your preferred Postgres client

type Flag = {
  key: string;
  enabled: boolean;
  rollout_pct: number;
};

// In-memory cache with a short TTL to avoid hammering the DB on every request
let cache: Map<string, Flag> = new Map();
let cacheExpiresAt = 0;
const CACHE_TTL_MS = 30_000; // 30 seconds

async function loadFlags(): Promise<void> {
  if (Date.now() < cacheExpiresAt) return;
  const { rows } = await sql<Flag>`SELECT key, enabled, rollout_pct FROM feature_flags`;
  cache = new Map(rows.map(r => [r.key, r]));
  cacheExpiresAt = Date.now() + CACHE_TTL_MS;
}

export async function isEnabled(flagKey: string, userId?: string): Promise<boolean> {
  // Local override via environment variable — useful in development
  const envOverride = process.env[`FLAG_${flagKey.toUpperCase()}`];
  if (envOverride === 'true') return true;
  if (envOverride === 'false') return false;

  try {
    await loadFlags();
    const flag = cache.get(flagKey);
    if (!flag || !flag.enabled) return false;
    if (flag.rollout_pct >= 100) return true;

    // Deterministic rollout: hash the userId so the same user always gets the same experience
    if (!userId) return false;
    const hash = userId.split('').reduce((acc, c) => acc + c.charCodeAt(0), 0);
    return (hash % 100) < flag.rollout_pct;
  } catch {
    // Fail safely — if DB is unreachable, default to disabled
    return false;
  }
}

A few design decisions worth noting:

  • Environment variable overrides (FLAG_NEW_DASHBOARD=true) let developers enable any flag locally without touching the database. This keeps development fast and keeps the DB clean.
  • Deterministic hashing on userId ensures a user does not randomly flip between the old and new experience on each page load — a common complaint with naive rollout implementations.
  • In-memory caching with a TTL means you are not issuing a database query on every single server-side render. Thirty seconds is a reasonable default; adjust based on your traffic and how quickly you need flag changes to propagate.

Step 3: Use Flags in Next.js Server Components and API Routes

With the evaluation layer in place, using a flag anywhere in your app is a single async call.

In a Server Component (App Router):

// app/dashboard/page.tsx
import { isEnabled } from '@/lib/flags';
import { getCurrentUserId } from '@/lib/auth';

export default async function DashboardPage() {
  const userId = await getCurrentUserId();
  const showNewDashboard = await isEnabled('new_dashboard', userId);

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

In an API Route:

// app/api/reports/route.ts
import { isEnabled } from '@/lib/flags';

export async function GET(req: Request) {
  const userId = req.headers.get('x-user-id') ?? undefined;
  const useNewEngine = await isEnabled('new_report_engine', userId);
  // ...route logic
}

Because isEnabled is just an async function, it works identically in middleware, API routes, and server components — no SDK wrappers, no context providers, no magic.


Step 4: Manage Flags Without a UI (For Now)

You do not need a dashboard on day one. A simple admin API route protected by a secret header is enough to flip flags during a deployment:

curl -X POST https://yourapp.com/api/admin/flags \
  -H "x-admin-secret: $ADMIN_SECRET" \
  -d '{"key": "new_dashboard", "enabled": true, "rollout_pct": 20}'

When your team is ready for a proper UI, the same Postgres table powers it. You are not locked into anything.


What This Unlocks for Your Product Strategy

With this system running, your team can execute deployment patterns that most small SaaS products never attempt:

  • Dark launches — ship new backend logic to production with the flag disabled, validate it under real load, then enable it without a new deployment
  • Canary releases — roll out a risky change to 5% of users, monitor error rates, then increase to 25%, 50%, and 100% incrementally
  • A/B testing — run two versions of a feature simultaneously and measure conversion before committing to one
  • Instant kill switches — disable a broken feature in seconds by flipping a database row, without a hotfix deployment

Why This Matters for Your Project

If you are building or scaling a SaaS product, the ability to separate code deployment from feature release is one of the highest-leverage practices you can adopt. It reduces deployment anxiety, shortens feedback loops, and lets your team ship continuously without gambling on big-bang releases. The system described here costs nothing beyond your existing Postgres instance and takes less than two hours to implement. The only thing standing between your team and safer, faster deployments is the assumption that you need a paid tool to do it right.