Type‑Safe Runtime Feature Flags in Next.js: Scalable Toggles with Zod, React Context, and Edge Middleware
Introduction
Feature flags (a.k.a. toggles) are the backbone of continuous delivery. They let you ship code behind a switch, run A/B tests, or roll out functionality gradually. In a large Next.js codebase the biggest pain points are:
- Runtime safety – flags are often plain strings read from env vars, cookies, or a remote service. A typo can cause a silent failure.
- Duplication of logic – every component that needs a flag ends up importing the same helper, leading to divergent typings.
- Performance – fetching flag values on every request can add latency, especially when the check runs on the client and the server separately.
This article shows a pragmatic way to solve all three by combining three tools that already ship with most TypeScript‑first Next.js projects:
| Tool | Why it helps |
|---|---|
| Zod | Provides a runtime schema that mirrors TypeScript types. Mis‑typed flags are caught at startup or at the edge. |
| React Context | Gives every component a typed useFeatureFlag hook without prop‑drilling. |
| Edge Middleware | Resolves flags once per request, at the CDN edge, and injects them into the request headers for both server‑ and client‑side consumption. |
The result is a single source of truth for flags, type safety from dev‑time to runtime, and sub‑millisecond flag resolution for every page.
1. Defining the flag contract with Zod
Start by describing every flag your application cares about. Use Zod to create a schema; the resulting TypeScript type (FeatureFlags) will be used everywhere else.
// lib/featureFlagsSchema.ts
import { z } from 'zod';
export const featureFlagsSchema = z.object({
/** Enable the new checkout flow */
newCheckout: z.boolean().default(false),
/** Show the experimental search UI */
experimentalSearch: z.boolean().default(false),
/** Roll out the dark‑mode redesign to a percentage of users */
darkModeBeta: z
.union([z.literal('off'), z.literal('on'), z.number().int().min(0).max(100)])
.default('off'),
});
export type FeatureFlags = z.infer<typeof featureFlagsSchema>;
Why Zod?
- The schema is runtime‑validated (so malformed data from a remote source cannot corrupt your app).
- The
default()helpers guarantee a fallback value, eliminatingundefinedchecks. z.infergives us a single source of truth for the TypeScript type, avoiding manual duplication.
2. Centralising flag resolution in Edge Middleware
Next.js Edge Middleware runs on the V8 isolate that powers Vercel's edge network (or any compatible platform). It can read cookies, request headers, or call an internal flag service once per request, then forward the result to the rest of the stack.
// middleware.ts
import { NextResponse, type NextRequest } from 'next/server';
import { featureFlagsSchema, type FeatureFlags } from '@/lib/featureFlagsSchema';
// Simulated remote flag source – in reality you might call LaunchDarkly, ConfigCat, etc.
async function fetchRemoteFlags(req: NextRequest): Promise<Partial<FeatureFlags>> {
// Example: read a JWT claim or a cookie that contains a user segment
const segment = req.cookies.get('segment')?.value ?? 'control';
// Very small mock – replace with real fetch logic.
if (segment === 'beta') {
return { newCheckout: true, experimentalSearch: true, darkModeBeta: 50 };
}
return {};
}
export async function middleware(req: NextRequest) {
const remote = await fetchRemoteFlags(req);
const validated = featureFlagsSchema.parse(remote); // throws if invalid
// Encode as a JSON string in a custom header – safe because size < 4KB
const resp = NextResponse.next();
resp.headers.set('x-feature-flags', JSON.stringify(validated));
return resp;
}
// Apply to all routes (or narrow with matcher)
export const config = {
matcher: '/:path*',
};
Key points
| Step | Reason |
|---|---|
fetchRemoteFlags |
Isolated function that can be unit‑tested. |
featureFlagsSchema.parse |
Guarantees that any missing flag falls back to the defaults defined in the schema. |
Header x-feature-flags |
The cheapest way to share data between middleware, server‑side code, and the client (the client can read it via request.headers). |
Tip: Keep the header size under 4 KB (the edge limit). If you have many flags, consider compressing or splitting them into multiple headers.
3. Providing flags to the React tree with a typed Context
On the server side, Next.js can read the header directly from request.headers. On the client side, we expose the same data via a small utility that reads document.cookie (where we mirror the header in a cookie) or directly from window.__NEXT_DATA__.
// lib/FeatureFlagContext.tsx
import React, { createContext, useContext } from 'react';
import { type FeatureFlags } from '@/lib/featureFlagsSchema';
// Default values are the schema defaults – safe for SSR before middleware runs.
const defaultFlags: FeatureFlags = {
newCheckout: false,
experimentalSearch: false,
darkModeBeta: 'off',
};
const FeatureFlagContext = createContext<FeatureFlags>(defaultFlags);
export const FeatureFlagProvider: React.FC<{
flags: FeatureFlags;
children: React.ReactNode;
}> = ({ flags, children }) => (
<FeatureFlagContext.Provider value={flags}>
{children}
</FeatureFlagContext.Provider>
);
export const useFeatureFlag = <K extends keyof FeatureFlags>(key: K): FeatureFlags[K] => {
const flags = useContext(FeatureFlagContext);
return flags[key];
};
Wiring the provider in app/layout.tsx (Next.js 13+)
// app/layout.tsx
import './globals.css';
import { FeatureFlagProvider } from '@/lib/FeatureFlagContext';
import { type FeatureFlags } from '@/lib/featureFlagsSchema';
export default async function RootLayout({
children,
// `headers` works both in server components and edge runtime
// eslint-disable-next-line @typescript-eslint/no-explicit-any
...props
}: { children: React.ReactNode }) {
// Server‑side only: read the header injected by middleware
const header = (await import('next/headers')).headers();
const json = header.get('x-feature-flags') ?? '{}';
const flags: FeatureFlags = JSON.parse(json as string);
return (
<html lang="en">
<body>
<FeatureFlagProvider flags={flags}>{children}</FeatureFlagProvider>
</body>
</html>
);
}
The RootLayout runs once per request on the server, so the flag values are available to every downstream component without extra network hops.
Using the hook in a component
// components/CheckoutButton.tsx
import { useFeatureFlag } from '@/lib/FeatureFlagContext';
export default function CheckoutButton() {
const newCheckout = useFeatureFlag('newCheckout');
return (
<button className={newCheckout ? 'bg-green-600' : 'bg-blue-600'}>
{newCheckout ? 'New Checkout 🚀' : 'Classic Checkout'}
</button>
);
}
Because useFeatureFlag is generic, TypeScript will autocomplete flag names and enforce the correct return type (boolean for newCheckout, string | number for darkModeBeta, etc.).
4. Client‑side fallback when navigating via SPA
When a user navigates client‑side (e.g., using next/link), the server‑rendered HTML already contains the correct flags, but the client does not automatically re‑read the header. A lightweight fallback is to store the flag payload in a cookie that the middleware also sets:
// middleware.ts (add to the previous response)
resp.cookies.set('feature-flags', JSON.stringify(validated), {
httpOnly: true,
path: '/',
maxAge: 60 * 60, // 1 hour – adjust to your TTL
});
Then, on the client, we hydrate the context from that cookie once:
// lib/clientFlagInit.ts
import { featureFlagsSchema, type FeatureFlags } from '@/lib/featureFlagsSchema';
export function getClientFlags(): FeatureFlags {
const cookie = document.cookie
.split('; ')
.find((row) => row.startsWith('feature-flags='));
if (!cookie) return featureFlagsSchema.parse({}); // defaults
try {
const payload = JSON.parse(decodeURIComponent(cookie.split('=')[1]));
return featureFlagsSchema.parse(payload);
} catch {
// Corrupted cookie – fall back to defaults
return featureFlagsSchema.parse({});
}
}
Finally, initialize the provider in a client‑only wrapper:
// app/client-provider.tsx
'use client';
import { FeatureFlagProvider } from '@/lib/FeatureFlagContext';
import { getClientFlags } from '@/lib/clientFlagInit';
export default function ClientFlagProvider({ children }: { children: React.ReactNode }) {
const flags = getClientFlags();
return <FeatureFlagProvider flags={flags}>{children}</FeatureFlagProvider>;
}
Wrap the root of your client component tree (e.g., in app/layout.tsx after the <body> tag) with <ClientFlagProvider> so that SPA navigation never loses the flag state.
5. Adding a dynamic “percentage roll‑out” flag
The darkModeBeta flag in our schema accepts a number from 0 to 100. We can interpret it as a percentage rollout. The middleware can decide per request whether the user falls into the bucket.
// middleware.ts (inside fetchRemoteFlags or a helper)
function shouldEnablePercentage(flag: number, req: NextRequest): boolean {
// Simple deterministic hash: use the user’s id cookie or IP
const id = req.cookies.get('uid')?.value ?? req.ip ?? Math.random().toString();
const hash = Number(BigInt('0x' + id.slice(0, 8))) % 100;
return hash < flag;
}
// Later, after fetching remote flags:
if (typeof remote.darkModeBeta === 'number') {
remote.darkModeBeta = shouldEnablePercentage(remote.darkModeBeta, req) ? 'on' : 'off';
}
Now the flag resolves to 'on' or 'off' for each request, while the TypeScript type still allows the original numeric configuration. UI components can treat it as a boolean:
// components/DarkModeToggle.tsx
import { useFeatureFlag } from '@/lib/FeatureFlagContext';
export default function DarkModeToggle() {
const darkMode = useFeatureFlag('darkModeBeta') === 'on';
return <div>{darkMode ? '🌙 Dark Mode' : '☀️ Light Mode'}</div>;
}
6. Testing the flag system
Because the schema lives in a single file, unit tests become trivial.
// __tests__/featureFlags.test.ts
import { featureFlagsSchema } from '@/lib/featureFlagsSchema';
test('schema applies defaults', () => {
const parsed = featureFlagsSchema.parse({});
expect(parsed).toEqual({
newCheckout: false,
experimentalSearch: false,
darkModeBeta: 'off',
});
});
test('invalid flag throws', () => {
expect(() =>
featureFlagsSchema.parse({ darkModeBeta: 150 })
).toThrow();
});
Integration tests can spin up the Edge Middleware (using next-test-api-route-handler or similar) and assert that the header is correctly populated.
7. Scaling considerations
| Concern | Strategy |
|---|---|
| Number of flags | Group related flags into nested objects (featureFlagsSchema.shape.checkout) and flatten only the needed slice into the header. |
| Cache‑ability | Add a Cache-Control: public, max-age=30 header to the middleware response when flags are static for a short window. Edge caches will then serve the same header without re‑executing the fetch. |
| Security | Keep the flag header httpOnly when you don’t want the client to tamper with it. If a flag must be secret (e.g., a feature‑gate for internal users), never expose it to the client; instead, guard the server‑only code paths. |
| Observability | Log the resolved flag payload (masked as needed) to your edge logs. Coupled with a request ID, you can trace why a user saw a particular UI variation. |
8. Recap and next steps
- Define a single Zod schema that represents the contract of every runtime flag.
- Resolve flags once per request in Edge Middleware, validate them, and push the serialized payload into a custom response header (and optionally a secure cookie).
- Consume the payload in a typed React Context that supplies a
useFeatureFlaghook, guaranteeing autocomplete and compile‑time correctness. - Handle client‑side navigation by hydrating from the same cookie, keeping the SPA experience seamless.
- Extend with percentage roll‑outs, caching, and observability as your product grows.
By anchoring the flag lifecycle at the edge and letting TypeScript + Zod enforce the shape, you eliminate a whole class of bugs that traditionally surface only in production. The pattern scales from a handful of toggles to hundreds, while keeping the developer experience pleasant and the runtime overhead negligible.
Happy flagging!
Member discussion