Feature Flags in Next.js SaaS: Implementation Guide for 2026
How to implement feature flags in a Next.js SaaS—covering environment-based flags, database-driven rollouts, plan-gating, A/B testing patterns, and the operational tradeoffs of each approach.
Contents
Feature flags let you decouple deployment from release. You ship code to production, but control when users see it. For a SaaS, that means plan-gating new features, running A/B tests without separate deployments, and doing gradual rollouts without managing multiple branches.
Most teams reach for a third-party service (LaunchDarkly, Flagsmith) before understanding what they actually need. This guide covers when that's justified and when environment variables or a database column is enough.
What Feature Flags Actually Solve
There are four distinct use cases that often get conflated:
- Kill switches: turn off a feature in production without a deploy.
- Plan gating: show a feature only to users on paid tiers.
- Gradual rollout: roll out to 10% of users, then 50%, then 100%.
- A/B testing: show variant A to half your users, variant B to the other half, measure conversion.
You don't need the same infrastructure for all four. A hardcoded config file handles kill switches. A database column handles plan gating. Gradual rollout and A/B testing are where complexity grows.
Option 1: Environment-Based Flags (Simplest)
For kill switches and development-only features, environment variables are enough.
// lib/flags.ts
export const flags = {
newDashboard: process.env.NEXT_PUBLIC_FLAG_NEW_DASHBOARD === "true",
betaAnalytics: process.env.NEXT_PUBLIC_FLAG_BETA_ANALYTICS === "true",
adminDebugPanel: process.env.FLAG_ADMIN_DEBUG === "true", // server-only
} as const;
export type FeatureFlag = keyof typeof flags;// Usage in a component
import { flags } from "@/lib/flags";
export function DashboardLayout() {
return (
<div>
{flags.newDashboard ? <NewDashboard /> : <LegacyDashboard />}
</div>
);
}What you get: instant deployment gating, zero database overhead, no external dependency.
What you don't get: per-user control, runtime changes without a deploy, analytics on flag exposure.
NEXT_PUBLIC_ prefix matters
In Next.js, environment variables prefixed with NEXT_PUBLIC_ are inlined at build time and available in client components. Server-only flags (admin tooling, debug features) should omit the prefix—they're never sent to the client.
Option 2: Database-Driven Flags (Plan Gating)
For SaaS plan gating, the flag state lives with the user's subscription record. This is not a "feature flag" system—it's just your billing model expressed in data.
// db/schema.ts (Drizzle)
export const users = pgTable("user", {
id: text("id").primaryKey(),
email: text("email").notNull().unique(),
plan: text("plan", { enum: ["free", "pro", "enterprise"] })
.notNull()
.default("free"),
// Fine-grained overrides for specific users
features: jsonb("features").$type<Record<string, boolean>>().default({}),
});// lib/features.ts
import { db } from "@/db";
import { users } from "@/db/schema";
import { eq } from "drizzle-orm";
const PLAN_FEATURES: Record<string, string[]> = {
free: ["basic_dashboard", "export_csv"],
pro: ["basic_dashboard", "export_csv", "advanced_analytics", "api_access", "webhooks"],
enterprise: ["basic_dashboard", "export_csv", "advanced_analytics", "api_access", "webhooks", "sso", "audit_log"],
};
export async function hasFeature(userId: string, feature: string): Promise<boolean> {
const user = await db
.select({ plan: users.plan, features: users.features })
.from(users)
.where(eq(users.id, userId))
.limit(1)
.then((rows) => rows[0]);
if (!user) return false;
// Check individual overrides first (for beta users, grandfathered plans, etc.)
if (user.features && feature in user.features) {
return user.features[feature] as boolean;
}
// Fall back to plan-based access
return PLAN_FEATURES[user.plan]?.includes(feature) ?? false;
}// Usage in an API route
export async function GET(req: Request) {
const session = await auth();
if (!session?.user?.id) return new Response(null, { status: 401 });
const canUseWebhooks = await hasFeature(session.user.id, "webhooks");
if (!canUseWebhooks) {
return Response.json({ error: "Webhooks require Pro plan" }, { status: 403 });
}
// ... handle request
}Gate at the API layer, not only the UI
Hiding a button in the UI is not access control. Always check feature access in the API route or server action. A determined user can call your API directly.
Option 3: A Feature Flags Table (Runtime Changes)
If you need to change flag state without a deploy—rolling out to a percentage of users, enabling for specific user IDs, or toggling a kill switch from an admin panel—add a dedicated table.
// db/schema.ts (Drizzle)
export const featureFlags = pgTable("feature_flag", {
id: text("id").primaryKey(),
name: text("name").notNull().unique(),
enabled: boolean("enabled").notNull().default(false),
rolloutPercent: integer("rollout_percent").notNull().default(0), // 0-100
enabledForUserIds: jsonb("enabled_for_user_ids")
.$type<string[]>()
.default([]),
updatedAt: timestamp("updated_at").defaultNow(),
});// lib/feature-flags.ts
import { db } from "@/db";
import { featureFlags } from "@/db/schema";
import { eq } from "drizzle-orm";
type FlagResult = {
enabled: boolean;
source: "disabled" | "user_override" | "rollout" | "global";
};
export async function evaluateFlag(
flagName: string,
userId: string
): Promise<FlagResult> {
const flag = await db
.select()
.from(featureFlags)
.where(eq(featureFlags.name, flagName))
.limit(1)
.then((rows) => rows[0]);
if (!flag || !flag.enabled) {
return { enabled: false, source: "disabled" };
}
// Explicit user override (beta users, internal team)
if (flag.enabledForUserIds?.includes(userId)) {
return { enabled: true, source: "user_override" };
}
// Percentage rollout: deterministic based on userId hash
if (flag.rolloutPercent > 0 && flag.rolloutPercent < 100) {
const hash = simpleHash(userId + flagName) % 100;
const inRollout = hash < flag.rolloutPercent;
return { enabled: inRollout, source: "rollout" };
}
// Global flag
return { enabled: flag.rolloutPercent === 100, source: "global" };
}
function simpleHash(str: string): number {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = (hash << 5) - hash + str.charCodeAt(i);
hash |= 0;
}
return Math.abs(hash);
}The deterministic hash on userId + flagName ensures the same user always gets the same flag value within a rollout—no flickering between page loads.
Option 4: Third-Party Flag Services
Services like LaunchDarkly, Flagsmith, Unleash, and GrowthBook add:
- Real-time flag evaluation (no database query per request)
- Built-in A/B testing with statistical significance tracking
- Audit logs and flag history
- Targeting rules (country, device, user attributes)
- SDK with caching and streaming updates
When it's worth it: when your team is running multiple concurrent experiments, when you need statistical rigor on A/B tests, or when non-engineers (product, growth) need to manage flags without code deployments.
When it's not worth it: early-stage SaaS where you have < 5 active flags, no A/B testing program, and don't need real-time flag changes.
GrowthBook is the open-source option worth considering—it runs on your infrastructure and has a solid SDK.
// GrowthBook example
import { GrowthBook } from "@growthbook/growthbook";
const gb = new GrowthBook({
apiHost: process.env.GROWTHBOOK_API_HOST,
clientKey: process.env.GROWTHBOOK_CLIENT_KEY,
});
await gb.loadFeatures();
gb.setAttributes({ id: userId, plan: userPlan });
const showNewCheckout = gb.isOn("new-checkout-flow");Caching Flag Evaluations
If you're using a database-backed flag system, cache aggressively. A flag lookup on every API request is unnecessary database load.
// lib/feature-flags-cached.ts
import { redis } from "@/lib/redis";
import { evaluateFlag } from "@/lib/feature-flags";
const FLAG_CACHE_TTL = 60; // 60 seconds
export async function evaluateFlagCached(
flagName: string,
userId: string
): Promise<boolean> {
const cacheKey = `flag:${flagName}:${userId}`;
const cached = await redis.get(cacheKey);
if (cached !== null) return cached === "1";
const result = await evaluateFlag(flagName, userId);
await redis.set(cacheKey, result.enabled ? "1" : "0", { ex: FLAG_CACHE_TTL });
return result.enabled;
}
// When a flag changes in the admin panel, invalidate cache:
export async function invalidateFlagCache(flagName: string): Promise<void> {
// Use SCAN to find all keys matching the pattern
const keys = await redis.keys(`flag:${flagName}:*`);
if (keys.length > 0) {
await redis.del(...keys);
}
}Redis KEYS is O(N) — use with care in production
redis.keys() scans all keys and blocks the server. For production flag invalidation with large user bases, use a Redis hash or a dedicated invalidation strategy instead of KEYS pattern.
Admin Panel Integration
Feature flags are only useful if you can change them. Wire up your admin panel to the flag table so you can toggle flags, adjust rollout percentages, and add user overrides without a database console.
Add flag management routes to your admin section (/admin/flags).
List all flags with their current state, rollout percentage, and last-updated timestamp.
Provide toggle, rollout slider, and user-override inputs.
On save, update the database record and call invalidateFlagCache(flagName).
Log all flag changes to an audit trail (who changed what, when, old value vs new value).
Common Mistakes
Choosing Your Approach
| Situation | Recommended approach |
|---|---|
| Kill switches only | Environment variables |
| Plan-based feature gating | Database column (plan field) |
| Beta user access | Per-user overrides in features jsonb |
| Gradual rollout without deploy | Feature flags table + hash rollout |
| A/B testing with statistics | GrowthBook (self-hosted) or LaunchDarkly |
| Non-engineer flag management | Third-party service or custom admin panel |
Next Steps
- How to Build an AI SaaS with Next.js — architectural decisions including plan gating and usage limits.
- Stripe Webhooks for SaaS — keeping your plan data accurate so plan-based flags reflect reality.
- ShipAI Production Playbook — operational baseline including flag-driven deployments.