Back to Journal
SaaS Engineering

How to Build Feature Flag Architecture Using Nextjs

Step-by-step tutorial for building Feature Flag Architecture with Nextjs, from project setup through deployment.

Muneer Puthiya Purayil 23 min read

This tutorial walks through building a feature flag system in Next.js that works seamlessly with both server and client components. You'll implement server-side flag evaluation, a React context for client components, percentage rollouts, and a management dashboard.

Project Setup

bash
1npx create-next-app@latest feature-flags-app --typescript --tailwind --app --src-dir
2cd feature-flags-app
3npm install zod
4 

Flag Configuration

Start with a file-based flag system that you can later migrate to a database:

typescript
1// src/lib/flags/config.ts
2import { z } from "zod";
3 
4const ConditionSchema = z.object({
5 attribute: z.string(),
6 operator: z.enum(["eq", "neq", "in", "contains", "starts_with"]),
7 values: z.array(z.string()),
8});
9 
10const RuleSchema = z.object({
11 conditions: z.array(ConditionSchema),
12 variant: z.string().default(""),
13 priority: z.number().default(0),
14});
15 
16const FlagConfigSchema = z.object({
17 key: z.string(),
18 enabled: z.boolean(),
19 percentage: z.number().min(0).max(100).default(100),
20 rules: z.array(RuleSchema).default([]),
21 defaultVariant: z.string().default(""),
22 type: z.enum(["release", "experiment", "ops", "permission"]),
23 description: z.string().optional(),
24});
25 
26export type FlagConfig = z.infer<typeof FlagConfigSchema>;
27export type EvalContext = {
28 userId: string;
29 email?: string;
30 plan?: string;
31 country?: string;
32 properties?: Record<string, string>;
33};
34 
35export type EvalResult = {
36 enabled: boolean;
37 variant: string;
38 reason: string;
39};
40 

Evaluation Engine

typescript
1// src/lib/flags/evaluator.ts
2import { createHash } from "crypto";
3import type { FlagConfig, EvalContext, EvalResult } from "./config";
4 
5let flagCache: Map<string, FlagConfig> = new Map();
6 
7export function updateFlags(configs: FlagConfig[]): void {
8 const newCache = new Map<string, FlagConfig>();
9 for (const config of configs) {
10 newCache.set(config.key, config);
11 }
12 flagCache = newCache;
13}
14 
15export function evaluate(flagKey: string, context: EvalContext): EvalResult {
16 const flag = flagCache.get(flagKey);
17 if (!flag || !flag.enabled) {
18 return { enabled: false, variant: "", reason: "disabled" };
19 }
20 
21 const sortedRules = [...flag.rules].sort((a, b) => b.priority - a.priority);
22 for (const rule of sortedRules) {
23 if (rule.conditions.every((c) => matchCondition(c, context))) {
24 return { enabled: true, variant: rule.variant, reason: "rule_match" };
25 }
26 }
27 
28 if (flag.percentage > 0 && flag.percentage < 100) {
29 const bucket = hashBucket(flagKey, context.userId);
30 if (bucket < flag.percentage) {
31 return { enabled: true, variant: flag.defaultVariant, reason: "percentage" };
32 }
33 return { enabled: false, variant: "", reason: "percentage_excluded" };
34 }
35 
36 return { enabled: true, variant: flag.defaultVariant, reason: "default" };
37}
38 
39export function isEnabled(flagKey: string, context: EvalContext): boolean {
40 return evaluate(flagKey, context).enabled;
41}
42 
43function hashBucket(flagKey: string, userId: string): number {
44 const hash = createHash("sha256").update(`${flagKey}:${userId}`).digest();
45 return (hash.readUInt32BE(0) / 0xffffffff) * 100;
46}
47 
48function matchCondition(cond: { attribute: string; operator: string; values: string[] }, ctx: EvalContext): boolean {
49 const value = getAttribute(cond.attribute, ctx);
50 switch (cond.operator) {
51 case "eq": return cond.values[0] === value;
52 case "neq": return cond.values[0] !== value;
53 case "in": return cond.values.includes(value);
54 case "contains": return value.includes(cond.values[0]);
55 case "starts_with": return value.startsWith(cond.values[0]);
56 default: return false;
57 }
58}
59 
60function getAttribute(attr: string, ctx: EvalContext): string {
61 const map: Record<string, string | undefined> = {
62 user_id: ctx.userId, email: ctx.email, plan: ctx.plan, country: ctx.country,
63 };
64 return map[attr] ?? ctx.properties?.[attr] ?? "";
65}
66 

Server Component Integration

typescript
1// src/lib/flags/server.ts
2import { cookies, headers } from "next/headers";
3import { evaluate, isEnabled } from "./evaluator";
4import type { EvalContext, EvalResult } from "./config";
5 
6export async function getServerFlagContext(): Promise<EvalContext> {
7 const headerStore = await headers();
8 return {
9 userId: headerStore.get("x-user-id") ?? "",
10 plan: headerStore.get("x-plan") ?? "",
11 email: headerStore.get("x-email") ?? "",
12 country: headerStore.get("cf-ipcountry") ?? "",
13 };
14}
15 
16export async function serverFlag(flagKey: string): Promise<boolean> {
17 const ctx = await getServerFlagContext();
18 return isEnabled(flagKey, ctx);
19}
20 
21export async function serverFlagVariant(flagKey: string): Promise<EvalResult> {
22 const ctx = await getServerFlagContext();
23 return evaluate(flagKey, ctx);
24}
25 
26// Usage in server component
27// app/dashboard/page.tsx
28export default async function DashboardPage() {
29 const showAnalytics = await serverFlag("analytics-dashboard");
30 const checkoutResult = await serverFlagVariant("new-checkout");
31 
32 return (
33 <div>
34 <h1>Dashboard</h1>
35 {showAnalytics && <AnalyticsWidget />}
36 {checkoutResult.enabled && checkoutResult.variant === "v2" && <CheckoutV2Banner />}
37 </div>
38 );
39}
40 

Need a second opinion on your saas engineering architecture?

I run free 30-minute strategy calls for engineering teams tackling this exact problem.

Book a Free Call

Client Component Integration

typescript
1// src/lib/flags/client.tsx
2"use client";
3import { createContext, useContext, useMemo, ReactNode } from "react";
4import type { EvalResult } from "./config";
5 
6interface FlagContextValue {
7 flags: Record<string, EvalResult>;
8 isEnabled: (key: string) => boolean;
9 getVariant: (key: string) => string;
10}
11 
12const FlagContext = createContext<FlagContextValue | null>(null);
13 
14export function FlagProvider({
15 children,
16 initialFlags,
17}: {
18 children: ReactNode;
19 initialFlags: Record<string, EvalResult>;
20}) {
21 const value = useMemo(
22 () => ({
23 flags: initialFlags,
24 isEnabled: (key: string) => initialFlags[key]?.enabled ?? false,
25 getVariant: (key: string) => initialFlags[key]?.variant ?? "",
26 }),
27 [initialFlags]
28 );
29 
30 return <FlagContext.Provider value={value}>{children}</FlagContext.Provider>;
31}
32 
33export function useFlag(flagKey: string): boolean {
34 const ctx = useContext(FlagContext);
35 if (!ctx) throw new Error("useFlag must be used within FlagProvider");
36 return ctx.isEnabled(flagKey);
37}
38 
39export function useFlagVariant(flagKey: string): string {
40 const ctx = useContext(FlagContext);
41 if (!ctx) throw new Error("useFlagVariant must be used within FlagProvider");
42 return ctx.getVariant(flagKey);
43}
44 

Wire it up in the layout:

typescript
1// app/layout.tsx
2import { FlagProvider } from "@/lib/flags/client";
3import { evaluate } from "@/lib/flags/evaluator";
4import { getServerFlagContext } from "@/lib/flags/server";
5 
6const CLIENT_FLAGS = ["new-checkout", "ai-suggestions", "dark-mode"];
7 
8export default async function RootLayout({ children }: { children: React.ReactNode }) {
9 const ctx = await getServerFlagContext();
10 const flags: Record<string, any> = {};
11 for (const key of CLIENT_FLAGS) {
12 flags[key] = evaluate(key, ctx);
13 }
14 
15 return (
16 <html>
17 <body>
18 <FlagProvider initialFlags={flags}>{children}</FlagProvider>
19 </body>
20 </html>
21 );
22}
23 

Flag Management API Routes

typescript
1// app/api/flags/route.ts
2import { NextResponse } from "next/server";
3import { prisma } from "@/lib/prisma";
4import { updateFlags } from "@/lib/flags/evaluator";
5 
6export async function GET() {
7 const flags = await prisma.featureFlag.findMany({
8 orderBy: { createdAt: "desc" },
9 });
10 return NextResponse.json(flags);
11}
12 
13export async function POST(request: Request) {
14 const body = await request.json();
15 const flag = await prisma.featureFlag.create({ data: body });
16 
17 // Refresh evaluator cache
18 const allFlags = await prisma.featureFlag.findMany();
19 updateFlags(allFlags);
20 
21 return NextResponse.json(flag, { status: 201 });
22}
23 
24// app/api/flags/[key]/route.ts
25export async function PUT(request: Request, { params }: { params: { key: string } }) {
26 const body = await request.json();
27 const flag = await prisma.featureFlag.update({
28 where: { key: params.key },
29 data: body,
30 });
31 
32 const allFlags = await prisma.featureFlag.findMany();
33 updateFlags(allFlags);
34 
35 return NextResponse.json(flag);
36}
37 
38// app/api/flags/evaluate/route.ts
39export async function POST(request: Request) {
40 const { flagKey, userId, plan, email } = await request.json();
41 const result = evaluate(flagKey, { userId, plan, email });
42 return NextResponse.json(result);
43}
44 

Conditional Component Rendering

typescript
1// src/components/Feature.tsx
2"use client";
3import { useFlag } from "@/lib/flags/client";
4 
5export function Feature({
6 flag,
7 children,
8 fallback = null,
9}: {
10 flag: string;
11 children: React.ReactNode;
12 fallback?: React.ReactNode;
13}) {
14 const enabled = useFlag(flag);
15 return enabled ? <>{children}</> : <>{fallback}</>;
16}
17 
18// Usage
19<Feature flag="ai-suggestions" fallback={<BasicSearch />}>
20 <AISearchPanel />
21</Feature>
22 

Testing

typescript
1// __tests__/evaluator.test.ts
2import { evaluate, updateFlags, isEnabled } from "@/lib/flags/evaluator";
3 
4describe("Flag Evaluator", () => {
5 beforeEach(() => {
6 updateFlags([
7 { key: "test-flag", enabled: true, percentage: 50, rules: [], defaultVariant: "", type: "release" },
8 { key: "plan-gate", enabled: true, percentage: 100, rules: [
9 { conditions: [{ attribute: "plan", operator: "in", values: ["pro", "enterprise"] }], variant: "full", priority: 1 },
10 ], defaultVariant: "limited", type: "permission" },
11 ]);
12 });
13 
14 test("percentage rollout has correct distribution", () => {
15 let enabled = 0;
16 for (let i = 0; i < 10_000; i++) {
17 if (isEnabled("test-flag", { userId: `user-${i}` })) enabled++;
18 }
19 expect(enabled / 100).toBeGreaterThanOrEqual(48);
20 expect(enabled / 100).toBeLessThanOrEqual(52);
21 });
22 
23 test("plan-based targeting works", () => {
24 expect(isEnabled("plan-gate", { userId: "1", plan: "enterprise" })).toBe(true);
25 expect(isEnabled("plan-gate", { userId: "2", plan: "free" })).toBe(false);
26 });
27});
28 

Conclusion

Next.js's server component architecture makes feature flags particularly clean. Server components evaluate flags during rendering with zero client-side JavaScript overhead. Client components receive pre-evaluated flag states through the FlagProvider, avoiding flash-of-content issues and unnecessary re-renders. The API routes provide a management interface that can be consumed by a dashboard or CLI tool.

This implementation starts simple — file-based configuration with in-memory evaluation — and scales cleanly to database-backed flags with real-time sync. The key architectural insight is evaluating flags on the server and passing results to client components through the context provider, rather than making client-side API calls for flag evaluation.

FAQ

Need expert help?

Building with saas engineering?

I help teams ship production-grade systems. From architecture review to hands-on builds.

Muneer Puthiya Purayil

SaaS Architect & AI Systems Engineer. 10+ years shipping production infrastructure across fintech, automotive, e-commerce, and healthcare.

Engage

Start a
Conversation.

For teams building at scale: SaaS platforms, agentic AI systems, and enterprise mobile infrastructure. Scope and fit are evaluated before any engagement begins.

Limited availability · Q3 / Q4 2026