The Flags SDK adapter.
@dif.sh/flags-sdk is an adapter for the Flags SDK, the flags package used by most Next.js apps. Your flag() declarations resolve from the same .md files and the same
generated client as the rest of dif, and evaluating a flag still makes no network call.
Reach for it when the app already declares flags with flags/next or flags/sveltekit, or when you want flags to show up in Vercel's Flags Explorer. If you
don't use the Flags SDK, call dif() or assign() directly instead — the typed
client is the shorter path.
The package exports two things:
difAdapterandcreateDifAdapter, the adapter instance.getProviderData, for the Flags Explorer.
Install
$npm install flags @dif.sh/flags-sdk @dif.sh/sdk $npm install -D @dif.sh/cli
@dif.sh/sdk is a peer dependency, and your install must
hold exactly one copy of it. The generated client registers experiments into a module-level
registry inside the SDK, and the adapter reads that same registry — a second nested copy resolves
to an empty one, and every flag falls back to its defaultValue.Declare flags
A flag is a .md file like any other dif experiment. Scaffold the workspace and draft
one:
$npx @dif.sh/cli init $npx @dif.sh/cli new new-checkout --surface checkout
Declare the variants and set status: active. The first variant is the fallback; raise
the second one's weight toward 100 to roll the flag out:
# dif/experiments/active/new-checkout.md id: new-checkout status: active owner: ada@acme.dev surface: checkout variants: - id: "off" weight: 90 - id: "on" weight: 10 metrics: primary: completed_checkout
dif build compiles the active files into dif/generated/client.ts, which dif init gitignores. Regenerate it before every build:
// package.json "scripts": { "prebuild": "dif build" }
Import the generated client once in flags.ts, then declare the flags. The flag key is the experiment id:
// flags.ts import "./dif/generated/client"; import { flag } from "flags/next"; import { difAdapter } from "@dif.sh/flags-sdk"; export const newCheckoutFlag = flag<boolean>({ key: "new-checkout", defaultValue: false, adapter: difAdapter.isEnabled(), }); export const heroCtaFlag = flag<string>({ key: "home-hero-cta", defaultValue: "control", adapter: difAdapter.variant(), });
difAdapter.variant()resolves to the assigned variant id, such ascontrolorvariant_a.difAdapter.isEnabled()resolves totruewhen the assigned variant is not the first declared one.difAdapter.isEnabled({ on: "variant_b" })istrueonly forvariant_b.
Then call the flag where you render:
// app/page.tsx import { newCheckoutFlag } from "@/flags"; export default async function Page() { const newCheckout = await newCheckoutFlag(); return newCheckout ? <OnePageCheckout /> : <Checkout />; }
Adapter options
difAdapter is a default instance. Use createDifAdapter to change its
defaults:
// flags.ts import { createDifAdapter } from "@dif.sh/flags-sdk"; const difAdapter = createDifAdapter({ overrides: process.env.VERCEL_ENV !== "production", origin: (key) => `https://github.com/acme/app/blob/main/dif/experiments/active/${key}.md`, });
| option | default | notes |
|---|---|---|
cookieName | dif_uid | Cookie the default identify reads the user id from. |
overrides | true | Honor the _dif preview cookie. dif qa --user u_1 --force new-checkout=on prints the matching ?_dif= link. |
onExposure | — | Called with { key, variant, bucket, userId } when a request is
bucketed. See exposures. |
origin | — | URL the Flags Explorer links each flag to — point it at the .md file. |
Identify users
identify runs during rendering, where cookies can't be set, so mint the anonymous id
in middleware. Setting it on the forwarded request lets the first render read it; setting it on the
response persists it:
// middleware.ts — on Next.js 16, name the file proxy.ts and export `proxy` import { type NextRequest, NextResponse } from "next/server"; import { DIF_OVERRIDES_COOKIE, DIF_UID_COOKIE } from "@dif.sh/flags-sdk"; export function middleware(request: NextRequest) { const uid = request.cookies.get(DIF_UID_COOKIE)?.value ?? crypto.randomUUID(); const force = request.nextUrl.searchParams.get(DIF_OVERRIDES_COOKIE); // forward on the request so identify() sees them during this render request.cookies.set(DIF_UID_COOKIE, uid); if (force !== null) request.cookies.set(DIF_OVERRIDES_COOKIE, force); const response = NextResponse.next({ request: { headers: request.headers } }); response.cookies.set(DIF_UID_COOKIE, uid, { path: "/", sameSite: "lax", maxAge: 60 * 60 * 24 * 365, }); return response; }
To bucket signed-in users on their account id, or to pass the attributes an experiment's audience block checks, supply
your own identify:
// flags.ts const identify = dedupe((async ({ headers, cookies }) => ({ userId: session?.userId ?? cookies.get(DIF_UID_COOKIE)?.value ?? null, attributes: { plan: session?.plan ?? "free" }, })) satisfies Identify<DifEntities>);
identify passes no attributes, so an
experiment with an audience block always serves its fallback until you provide one.
Attribute names must match the audience_attributes declared in dif/config.yaml.Fallbacks
For a flag whose variants are off and on:
| request | variant() | isEnabled() | exposure |
|---|---|---|---|
| user id set, audience matches | bucketed variant | variant !== "off" | fires |
| no user id | "off" | false | — |
| audience miss, or exclusion-group loss | "off" | false | — |
_dif preview force | forced variant | true | — |
| Flags Explorer override | override value | override value | — |
| id missing from the generated client | decide throws → defaultValue | — | |
When decide throws, the Flags SDK returns the flag's defaultValue and
logs the error; a flag declared without one rejects. The message names the fix: import ./dif/generated/client in flags.ts, run dif build, and check
the file is status: active.
Exposures
dif analyses an experiment from exposure events: one per user bucketed into a variant. The adapter
assigns on the server and fires no exposure of its own, so pass onExposure to record
them:
// flags.ts import { waitUntil } from "@vercel/functions"; const difAdapter = createDifAdapter({ onExposure: (exposure) => waitUntil(sendToAnalytics("dif.exposure", exposure)), });
- It runs once per flag per request and keeps no state across requests. Deduplicate on
(key, userId)in your pipeline. - It isn't awaited. Errors are logged and never change the flag value, so wrap network calls in
waitUntil. - Next.js renders prefetched routes, so an exposure can fire for a page the visitor never opens.
DifServer sends metrics, not exposures.
Forward onExposure to your own pipeline, or record the exposure in the browser with recordExposure() from @dif.sh/sdk.Flags Explorer
getProviderData emits every registered experiment for Vercel's Flags Explorer.
Importing flags.ts also imports the generated client, which fills the registry it
reads. Passing the flags exports gives isEnabled() flags false/true options instead of variant ids:
// app/.well-known/vercel/flags/route.ts import { createFlagsDiscoveryEndpoint } from "flags/next"; import { getProviderData } from "@dif.sh/flags-sdk"; import * as flags from "../../../../flags"; export const GET = createFlagsDiscoveryEndpoint(() => getProviderData({ flags }));
Each definition's description carries the experiment's surface, variant weights, fallback variant, and exclusion group.
invariantthe adapter never evaluates a flag over the network: it reads dif/generated/client.ts, so a flag resolves from the commit that's deployed.