Shipping Feature Flags in Next.js with the Vercel Flags SDK
Table of Contents
About the Author

Ahmed Mahmoud
Author & Developer
Software engineer passionate about web development and user experience design.
Founder of Devya · eng-ahmed.com ↗Key takeaways
The Flags SDK ships as the `flags` package, with a Next.js entry point at `flags/next` exporting a `flag()` function that declares one feature flag as a typed, callable piece of code.
A flag's `decide()` function runs server-side only, so calling the flag in a Server Component never leaks its targeting logic into the client JavaScript bundle — something a `NEXT_PUBLIC_*` environment variable cannot do.
Reading several flags with `evaluate()` instead of sequential `await` calls or `Promise.all()` batches the work; the Vercel adapter's `bulkDecide` hook gives roughly a 10x reduction in evaluation time when resolving hundreds of flags in one call.
The `precompute` pattern moves flag evaluation into Middleware and encodes the result into the URL, which lets a flag-gated page stay statically generated instead of falling onto the request-time rendering path.
A discovery route at `app/.well-known/vercel/flags/route.ts` feeds the Vercel Toolbar's Flags Explorer, so every flag and its current value is visible on a deployment without building a custom admin page.
What problem does the Flags SDK solve for us?
The Flags SDK closes the gap between a feature flag that is just a boolean and one that needs targeting, a dashboard, and caching behavior that doesn't hurt performance. We used to gate a client's features behind `process.env.NEXT_PUBLIC_NEW_PRICING`, which works until the flag needs a different value in preview versus production, or needs to roll out to 10% of users, or needs to flip off without a redeploy. A raw environment variable answers none of those needs, and hand-rolling a database table plus a cache in front of it means owning the schema, the cache invalidation, and an admin UI ourselves.
A flag declared with `flag()` from `flags/next` is a single exported async function per flag:
```ts // 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 rather than in another codebase-only config file. Adapters also exist for LaunchDarkly, Statsig, PostHog, GrowthBook, and other providers a client might already be paying for — the call site doesn't change, only the adapter import does.
How do we read a flag inside a Server Component?
Calling the exported flag function is the entire read API for one flag — no separate client needs configuring per route.
```tsx 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 guarantee a server-only module gives a secret API key. That's why we now default new flags to this pattern even for ones with no sensitive logic: the approach stays identical once a flag does need to check something we don't want visible in a bundle, like an internal user allowlist.
How do we target a specific user instead of a single global value?
We add an `identify` function that reads the request and returns the entity `decide()` evaluates against, wrapped in `dedupe` so it runs once per request even when several flags share it.
```ts 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 on the object `identify` returns — `user.id` here — are exactly what the dashboard's targeting rules match against. Get an attribute name wrong in one place and targeting silently stops matching anyone, with no error thrown.
How do we read several flags without serializing the latency?
We use `evaluate()` from `flags/next` whenever a page needs more than one flag's value, instead of two sequential `await` calls or a `Promise.all()`. Sequential awaits make total latency the sum of every flag's evaluation; `Promise.all()` runs them concurrently but still evaluates each flag in isolation. `evaluate()` reads headers, cookies, and overrides once for the whole batch and lets an adapter resolve the group in a single call, which is where the roughly 10x evaluation-time reduction on hundreds of flags comes from on the Vercel adapter's `bulkDecide` hook.
How do we keep a flag-gated page statically rendered?
A flag evaluated at request time pulls its page off the fully static rendering path, because the response now depends on something only known per request. The `precompute` pattern avoids that: Middleware evaluates a declared group of flags, calls `precompute(flagGroup)` to get a short code string back, and rewrites the request to a URL that encodes that code. The page then reads its flag values back out of the code with `await myFlag(code, flagGroup)` instead of re-running `decide()`, so it can be prerendered per code value and served from the CDN like any other static route.
Flags SDK vs. an environment variable vs. a hand-rolled table — which fits a given project?
An environment variable gives zero targeting, stays visible in the client bundle if marked public, and keeps pages static — fine for a single global switch and nothing else.
A hand-rolled database table plus cache gives whatever targeting you build, stays server-side if read correctly, but only keeps pages static if you build the caching layer yourself — and you own the schema, the cache, and the admin UI.
The Flags SDK with `vercelAdapter` gives built-in targeting via `identify` and dashboard rules, stays server-side, keeps pages static via `precompute`, and is managed by Vercel Flags while staying swappable to another provider through the adapter.
The Flags SDK with a third-party adapter (LaunchDarkly, Statsig, and others) gives whatever targeting that provider supports, stays server-side, keeps pages static via `precompute`, and is the right fit when a client already pays for that provider — the SDK is just the Next.js wiring on top.
FAQ
**Q: Does the Flags SDK replace `NEXT_PUBLIC_*` environment variables for feature flags?**
For anything beyond a single global on/off switch, yes. A `NEXT_PUBLIC_*` variable is visible in the client bundle and can't express per-user targeting or a staged rollout; a Flags SDK flag stays server-only and supports both.
**Q: Do we have to use Vercel Flags, or can a client keep their existing flag provider?**
The SDK ships adapters for LaunchDarkly, Statsig, PostHog, GrowthBook, Flagsmith, Optimizely, and OpenFeature, plus a documented interface for a custom adapter. `vercelAdapter` is the first-party option, not a requirement.
**Q: Why does adding a flag turn a static page into a dynamically rendered one?**
Calling `decide()` at request time means the response depends on something only known per request, which disqualifies full static rendering. The `precompute` pattern avoids this by resolving flags in Middleware ahead of the page render.
**Q: What does `FLAGS_SECRET` protect?**
It's a 32-byte, base64-encoded key used to encrypt flag values and definitions sent to the Flags Explorer, and to encrypt `precompute` codes, so flag internals aren't exposed as plain text in a URL or script tag.
**Q: Is `evaluate()` only worth using at large scale?**
No — it's correct for any page reading two or more flags, since it still saves re-reading cookies and headers per flag even with a handful of flags. The roughly 10x figure for `bulkDecide` is specifically about adapters resolving hundreds of flags in one remote call.
Further Reading
Frontend Engineering
Migrating vercel.json to vercel.ts: What We Learned Moving to Typed Vercel Config
vercel.ts replaces vercel.json with a typed, executable TypeScript config file. Here's what we hit migrating client projects — from caught typos to a config-drift bug caused by Date.now().
Frontend Engineering
Node.js 20 End-of-Life on Vercel: Our Migration to Node 24 LTS
Node.js 20 went end-of-life on Vercel on October 1, 2026. Here's what actually broke when our team migrated a handful of production projects to Node 24 LTS — and it wasn't application code.