How to Add Feature Flags to Your SaaS Starter (2026)
·StarterPick Team
feature-flagsnextjsguidedevops2026
TL;DR
Feature flags let you ship code without shipping features. For SaaS, they serve three purposes: plan-based gating (Pro only), progressive rollout (10% → 100%), and A/B testing. You can build a lightweight flags system in half a day or adopt a managed service like Unleash or Growthbook. This guide covers both paths.
Option 1: Lightweight DIY Flags (Recommended to Start)
For most SaaS apps, feature flags are just a database table and a helper function:
// prisma/schema.prisma
model FeatureFlag {
id String @id @default(cuid())
name String @unique
enabled Boolean @default(false)
enabledFor String[] // user IDs or organization IDs
rolloutPct Int @default(0) // 0-100 percentage rollout
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// lib/flags.ts
import { prisma } from './prisma';
export async function isFeatureEnabled(
flagName: string,
userId?: string
): Promise<boolean> {
const flag = await prisma.featureFlag.findUnique({
where: { name: flagName },
});
if (!flag) return false;
if (!flag.enabled) return false;
// Specific user override
if (userId && flag.enabledFor.includes(userId)) return true;
// Percentage rollout (deterministic per user)
if (flag.rolloutPct > 0 && userId) {
const hash = simpleHash(userId + flagName);
return hash % 100 < flag.rolloutPct;
}
// Fully enabled
return flag.rolloutPct === 100;
}
function simpleHash(str: string): number {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash;
}
return Math.abs(hash);
}
Plan-Based Feature Gating
The most common SaaS use case — gate features by subscription:
// lib/plan-features.ts
const PLAN_FEATURES: Record<string, string[]> = {
free: ['dashboard', 'basic_analytics'],
pro: ['dashboard', 'basic_analytics', 'advanced_analytics', 'api_access', 'team_members'],
enterprise: ['*'], // All features
};
export function planHasFeature(planName: string, feature: string): boolean {
const features = PLAN_FEATURES[planName] ?? [];
return features.includes('*') || features.includes(feature);
}
// In your API routes
export async function requireFeature(userId: string, feature: string) {
const user = await prisma.user.findUnique({
where: { id: userId },
include: { subscription: true },
});
const plan = user?.subscription?.priceId
? getPlanFromPriceId(user.subscription.priceId)
: 'free';
if (!planHasFeature(plan, feature)) {
throw new Error(`Feature '${feature}' requires ${getRequiredPlan(feature)} plan`);
}
}
Using Flags in Next.js
Server Components
// app/dashboard/analytics/page.tsx
import { isFeatureEnabled } from '@/lib/flags';
import { getServerSession } from 'next-auth';
export default async function AnalyticsPage() {
const session = await getServerSession();
const hasAdvancedAnalytics = await isFeatureEnabled(
'advanced_analytics',
session?.user.id
);
return (
<div>
<BasicAnalytics />
{hasAdvancedAnalytics && <AdvancedAnalytics />}
</div>
);
}
Client Components with Context
// providers/FlagsProvider.tsx
'use client';
import { createContext, useContext } from 'react';
type FlagsContextType = Record<string, boolean>;
const FlagsContext = createContext<FlagsContextType>({});
export function FlagsProvider({
flags,
children,
}: {
flags: FlagsContextType;
children: React.ReactNode;
}) {
return <FlagsContext.Provider value={flags}>{children}</FlagsContext.Provider>;
}
export function useFlag(name: string): boolean {
const flags = useContext(FlagsContext);
return flags[name] ?? false;
}
// Load flags in root layout and pass to provider
// app/layout.tsx
const session = await getServerSession();
const flags = session?.user.id
? await getUserFlags(session.user.id)
: {};
Option 2: Managed Flag Services
When you need targeting rules, analytics, and team collaboration:
| Service | Free Tier | Highlights |
|---|---|---|
| Growthbook | Self-host free | A/B testing built-in, open source |
| Unleash | Self-host free | Enterprise feature parity |
| Flagsmith | 50K requests/mo | Simple API, good SDKs |
| LaunchDarkly | 1 seat free | Best-in-class, expensive at scale |
Growthbook (Self-hosted, Free)
// lib/growthbook.ts
import { GrowthBook } from '@growthbook/growthbook';
export function createGrowthBook(userId: string, attributes: Record<string, unknown>) {
const gb = new GrowthBook({
apiHost: process.env.GROWTHBOOK_API_HOST!,
clientKey: process.env.GROWTHBOOK_CLIENT_KEY!,
attributes: {
id: userId,
...attributes,
},
});
return gb;
}
// Usage
const gb = createGrowthBook(user.id, {
plan: user.subscription?.plan,
country: user.country,
});
await gb.loadFeatures();
const showNewDashboard = gb.isOn('new_dashboard');
Admin UI for Flag Management
A simple admin page to toggle flags without code deploys:
// app/admin/flags/page.tsx
export default async function FlagsAdminPage() {
const flags = await prisma.featureFlag.findMany({
orderBy: { name: 'asc' },
});
return (
<div>
<h1>Feature Flags</h1>
{flags.map(flag => (
<div key={flag.id} className="flex items-center justify-between py-3 border-b">
<div>
<p className="font-medium">{flag.name}</p>
<p className="text-sm text-gray-500">Rollout: {flag.rolloutPct}%</p>
</div>
<form action={toggleFlag}>
<input type="hidden" name="id" value={flag.id} />
<input type="hidden" name="enabled" value={String(!flag.enabled)} />
<button type="submit" className={flag.enabled ? 'bg-green-500' : 'bg-gray-300'}>
{flag.enabled ? 'ON' : 'OFF'}
</button>
</form>
</div>
))}
</div>
);
}
Time Budget
| Component | Duration |
|---|---|
| DB schema + migration | 0.5 hour |
isFeatureEnabled helper | 1 hour |
| Plan-based gating | 1 hour |
| Server/client component usage | 1 hour |
| Admin toggle UI | 2 hours |
| Total | ~1 day |
Find boilerplates with feature flags built-in on StarterPick.
Check out this boilerplate
View ShipFast on StarterPick →