Vercel Flags SDK: Feature Flags in Next.js App Router, Field Notes
The Flags SDK (the flags npm package) turns a feature flag into a typed, server-only async function, and its precompute pattern is what lets a flag-gated page stay statically rendered instead of falling onto the request-time path.
Key takeaways
- The Flags SDK ships as the
flagspackage, with a Next.js entry point atflags/nextexporting aflag()function. - A flag's
decide()runs server-side only - nothing leaks into the client bundle, unlike aNEXT_PUBLIC_*env var. evaluate()batches multiple flag reads; the Vercel adapter'sbulkDecidehook gives roughly a 10x reduction in evaluation time on hundreds of flags.precomputemoves flag evaluation into Middleware so a flag-gated page can stay statically generated.- A discovery route at
app/.well-known/vercel/flags/route.tsfeeds the Vercel Toolbar's Flags Explorer.
What problem does the Flags SDK actually solve?
I'd gated features behind process.env.NEXT_PUBLIC_NEW_PRICING === 'true' for years. That works until I need a different value in preview versus production, a 10% rollout, or a kill switch with no redeploy.
A raw env var answers none of that, and a hand-rolled database table plus cache means owning the schema, the cache invalidation, and an admin UI myself.
A flag declared with flag() is a single exported async function:
// flags.ts
import { flag } from 'flags/next';
import { vercelAdapter } from '@flags-sdk/vercel';
export const showNewPricing = flag({
key: 'show-new-pricing',
description: 'Show the redesigned pricing page',
adapter: vercelAdapter,
});
vercelAdapter connects the flag to Vercel Flags, so the value and targeting rules live in the Vercel dashboard. Adapters also exist for LaunchDarkly, Statsig, PostHog, GrowthBook, and others - the call site doesn't change, only the adapter import.
How do I read a flag in a Server Component?
import { showNewPricing } from '../flags';
export default async function PricingPage() {
const enabled = await showNewPricing();
return enabled ? <NewPricing /> : <OldPricing />;
}
Only the resolved boolean reaches the rendered output - the same confidentiality a server-only module gives a secret API key.
How do I target a specific user instead of a global boolean?
Add an identify function wrapped in dedupe so it runs once per request even across several flags:
import { dedupe, flag } from 'flags/next';
import type { ReadonlyRequestCookies } from 'flags';
interface Entities {
user?: { id: string };
}
const identify = dedupe(
({ cookies }: { cookies: ReadonlyRequestCookies }): Entities => {
const userId = cookies.get('user-id')?.value;
return { user: userId ? { id: userId } : undefined };
},
);
export const newDashboard = flag<boolean, Entities>({
key: 'new-dashboard',
identify,
decide({ entities }) {
if (!entities?.user) return false;
return ['user_42', 'user_107'].includes(entities.user.id);
},
});
With vercelAdapter, the attribute names identify returns - user.id here - are exactly what the dashboard's targeting rules match against. Get the name wrong and targeting silently stops matching anyone.
How do I read several flags without serializing the latency?
Use evaluate() instead of sequential await calls or Promise.all():
import { evaluate } from 'flags/next';
import { showNewPricing, newDashboard } from '../flags';
const { showNewPricing: pricing, newDashboard: dashboard } = await evaluate({
showNewPricing,
newDashboard,
});
Sequential awaits sum every flag's latency. Promise.all() runs them concurrently but still evaluates each in isolation. evaluate() reads headers, cookies, and overrides once for the whole batch, which is where the Vercel adapter's bulkDecide hook gets its roughly 10x reduction in evaluation time on hundreds of flags.
How do I keep a flag-gated page statically rendered?
A flag evaluated at request time pulls its page off the static rendering path. precompute avoids that: Middleware evaluates a declared flag group, calls precompute(flagGroup) for a short code string, and rewrites the request to a URL encoding that code.
The page reads flag values back with await myFlag(code, flagGroup) instead of re-running decide(), so it prerenders per code value and serves from the CDN like any static route.
Flags SDK vs. an env var vs. a hand-rolled table
| Approach | Targeting | Stays server-only | Keeps pages static | Operational cost |
|---|---|---|---|---|
NEXT_PUBLIC_* env var |
None | No | Yes | None |
| Hand-rolled DB table + cache | Whatever you build | Yes, if read server-side | Only if you build the caching layer | You own schema, cache, admin UI |
| Flags SDK + vercelAdapter | Built in via identify + dashboard | Yes | Yes, via precompute | Managed by Vercel Flags |
| Flags SDK + third-party adapter | Whatever the provider supports | Yes | Yes, via precompute | You already pay for the provider |
FAQ
Q: Does the Flags SDK replace NEXT_PUBLIC_* env vars for feature flags?
A: For anything beyond a single global switch, yes - a Flags SDK flag stays server-only and supports per-user targeting, which a public env var can't.
Q: Do I have to use Vercel Flags?
A: No. Adapters exist for LaunchDarkly, Statsig, PostHog, GrowthBook, Flagsmith, Optimizely, and OpenFeature, plus a custom adapter interface.
Q: Why does a flag turn a static page into a dynamic one?
A: decide() at request time means the response depends on something only known per request. precompute avoids this by resolving flags in Middleware ahead of the page render.
Q: What does FLAGS_SECRET protect?
A: A 32-byte base64 key that encrypts flag values/definitions sent to Flags Explorer and encrypts precompute codes, so flag internals aren't plain text in a URL.
Q: Is evaluate() only useful at scale?
A: No - it's correct for any page reading two or more flags. The roughly 10x figure for bulkDecide is specifically about adapters resolving hundreds of flags in one call.
Originally published on devya.dev. Also on eng-ahmed.com. Built by Devya Solutions.
Comments
No comments yet. Start the discussion.