Feature flags in Next.js with the App Router
To use feature flags in Next.js with the App Router, resolve the variant on the server and let the client agree with it. Call assign() from @dif.sh/sdk inside a server component to pick a variant from the request’s user id, and wrap the client half of your tree in <DifProvider> so hooks below it read the same flag.
Most flag SDKs were written for a browser. In the App Router, that assumption breaks in two places. A provider-and-hook setup forces "use client" onto components that had no reason to ship JavaScript, and an SDK that fetches flag state at runtime renders the control on the server, then swaps to the real variant after hydration. Both problems come from evaluating a flag over the network.
The setup below resolves the variant on the server instead, and the last section says what that costs.
Key Takeaways
assign("id", { userId, attributes })from@dif.sh/sdkis the server-side call. It returns{ variant, bucket, exposed }, fires no exposure event, and returnsnullfor an unregistered id.- Server and client pick the same variant because assignment is the first four bytes of
SHA-256(salt || user_id)mod 10,000, a pure function of the user id rather than a fetch.@dif.sh/reacthas no request-scoped SSR helper yet. You wire the handoff withrecordExposure(id, variant, bucket)in a small client component. The Svelte adapter does it for you.- Reading
cookies()to resolve the user id opts that route into dynamic rendering. A statically rendered route bakes one variant at build time.- Rolling a flag back is editing a weight to
0and merging, so the kill takes as long as a Next.js build and deploy.
Why feature flags in Next.js need a server-side call
In the App Router, components are server components by default. They run once per request on the server and their code never reaches the browser. A hook cannot run there, so a flag SDK that exposes only a React hook can only be read below a "use client" boundary.
That boundary spreads. Gating a heading in a server-rendered page means marking it a client component, which marks its parent, and the bundle grows to answer a question the server already knew the answer to.
The second problem is timing. If the SDK fetches flag state on mount, the server has nothing to render, so it renders the control and the browser corrects it a moment later. The user sees the flash. React Server Components make that worse, not better, because more of the page is decided before any JavaScript runs.
dif avoids both because a variant is computed, not fetched. The flag is a Markdown file in your repo, dif build compiles it into a typed client, and the assignment is a hash of the user id. The same input gives the same variant on the server and in the browser.
Install dif and compile the flags
Install the CLI, scaffold the workspace, then add the runtime packages. The SDK and adapters need Node 20.6 or newer.
npm install -g @dif.sh/cli
dif init
npm install @dif.sh/sdk @dif.sh/react
dif init writes a dif/ directory. dif new drafts a flag file there, and it looks like this:
---
id: checkout-cta-v2
status: active
owner: sam@acme.com
surface: checkout
variants:
- id: control
weight: 50
- id: variant_a
weight: 50
---
Does naming the delivery promise in the button raise completed checkouts?
dif build compiles every active file into dif/generated/client.ts. Importing that module registers the specs with the runtime, which is what makes assign() and dif() resolve to something real. Skip the import and an unknown id safely returns the first variant and buckets nobody. Run dif validate in CI and a flag whose weights do not total 100, or whose owner is not a valid email, fails the pull request like a broken build. The CLI reference covers each verb.
Read a feature flag in a server component
assign() is the request-scoped API. You pass the user id and attributes explicitly, so nothing depends on a browser singleton and it is safe to call from a long-lived server process.
// app/checkout/page.tsx
import { cookies } from "next/headers";
import "@/dif/generated/client";
import { assign } from "@dif.sh/sdk";
export default async function CheckoutPage() {
const userId = (await cookies()).get("dif_uid")?.value ?? null;
const cta = assign("checkout-cta-v2", { userId, attributes: {} });
return <CheckoutCTA
label={cta?.variant === "variant_a" ? "Get it today" : "Place order"}
/>;
}
Three things about that call. It returns { variant, bucket, exposed, forced }, so you can pass the bucket down to the client. It fires no exposure event, which is deliberate: an exposure should mean the user saw the variant, not that the server computed one. And it returns null for an id that is not registered, so cta?.variant falls through to the control string instead of throwing.
That is the whole server-side setup: one call, reading one file. Moving the split from 50/50 to 90/10 later is a one-line diff in that file, approved in a pull request like any other change.
Gate a client component with the provider
Interactive components still need the hook. Put <DifProvider> in a client module and render it from the root layout:
// app/providers.tsx
"use client";
import "@/dif/generated/client";
import { DifProvider } from "@dif.sh/react";
export function Providers({ children }: { children: React.ReactNode }) {
return (
<DifProvider
config={{
project: "acme-shop",
publishableKey: process.env.NEXT_PUBLIC_DIF_PUBLISHABLE_KEY,
userId: () => readUidCookie(),
}}
>
{children}
</DifProvider>
);
}
Below it, useDif() returns { track, exposure }. Use exposure to read a flag and track to record the outcome you are measuring:
"use client";
import { useDif } from "@dif.sh/react";
export function CheckoutButton() {
const { exposure, track } = useDif();
const cta = exposure("checkout-cta-v2", {
control: () => "Place order",
variant_a: () => "Get it today",
});
return <button onClick={() => track("completed_checkout", { value: 49 })}>{cta()}</button>;
}
useDif() throws outside the provider, so the server/client boundary stays explicit instead of silently returning the control. The React guide goes deeper on the hook, and every dif.init option the provider accepts is listed in the SDK reference.
Hand the server assignment to the client without a flicker
The userId resolver has to produce the same value on both sides. A cookie is the simplest way to guarantee that, which is why the Svelte adapter mints a dif_uid cookie for anonymous users. In Next.js you set that cookie yourself, usually in middleware.ts, and read it with cookies() on the server and document.cookie on the client.
Get that right and there is nothing left to reconcile. The server rendered variant_a because the hash of dif_uid landed in that variant’s buckets, and the browser recomputes the identical number from the identical cookie.
What is left is the exposure. assign() does not fire one, so a server-rendered variant records nothing until the client says the user saw it:
// app/_dif/record-exposure.tsx
"use client";
import { useEffect } from "react";
import { recordExposure } from "@dif.sh/sdk";
export function RecordExposure({ id, variant, bucket }: {
id: string; variant: string; bucket: number;
}) {
useEffect(() => { recordExposure(id, variant, bucket); }, [id, variant, bucket]);
return null;
}
Render it next to the gated markup with the values assign() returned. It uses the browser SDK’s configured sinks and dedupes against later dif() calls, so a flag read on both sides counts once. This is manual work today: the React adapter ships no request-scoped SSR helper, and assign plus recordExposure is the documented way to build one. If the page has no client component at all, post the exposure from the server instead, with a secret key to POST /v1/exposure.
What this costs
Calling cookies() in a server component opts that route out of static rendering for the whole request. A per-user variant is not cacheable, so that is the right behavior, but it is a real change if the route was static before. A flag on a statically rendered route bakes one variant at build time for everyone, which works for a kill switch you flip in a deploy and does not work for a 50/50 test. Decide per route, not per app.
The second cost is the rollback. Turning a flag off means editing a weight to 0 and merging. If your Next.js build and deploy take four minutes, your worst-case kill is four minutes. For a payment path that needs a sub-second kill switch, a hosted flag service is the right tool for that flag. For most flags, where the failure mode is that some users see the new thing, a merge is fine, and every failure mode in dif resolves to the first variant and records nothing.
FAQ
How do I use feature flags in Next.js with the App Router? Call assign("flag-id", { userId, attributes }) from @dif.sh/sdk in a server component to get the variant, and wrap client components in <DifProvider> so useDif() reads the same flag. Both sides compute the assignment from the user id, so they agree without a network call.
Can I read a feature flag in a React Server Component? Yes, with assign(). Hooks cannot run in a server component, but assign() is a plain function that takes the request context explicitly and returns { variant, bucket, exposed }. Import dif/generated/client in the same module so the spec is registered.
Do feature flags work with static rendering in Next.js? Only for flags that are the same for every user. Resolving a per-user variant means reading cookies() or headers(), which makes the route dynamic. A statically rendered page can still be gated by a flag whose weight is 0 or 100, because that value is fixed at build time.
How do I stop a feature flag from flickering on hydration? Resolve the same user id on the server and the client, usually from one cookie. dif assigns by hashing that id, so the server markup and the first client render produce the same variant and React has nothing to correct.
Can I use feature flags in Next.js middleware? Use middleware for the piece it is good at: setting a stable dif_uid cookie for anonymous visitors before the page renders. Then do the assignment in the server component, where you have the full attribute bag.
Do I need a dif Cloud account for feature flags in Next.js? No. Assignment is local, so flags, rollouts, and A/B splits work with nothing hosted and no API key. Add a publishableKey only when you want exposure and outcome events sent to dif Cloud for analysis.
Getting started
Feature flags in Next.js come down to one decision: where the variant is resolved. Resolve it on the server with assign() and the App Router works the way it is meant to, with server components staying on the server and no flash after hydration. Resolve it in the browser and you inherit both problems the App Router was built to avoid.
The rest is small. dif init scaffolds the directory, dif new drafts the flag file, dif build compiles it to dif/generated/client.ts, and dif validate fails the pull request if the weights are wrong. It costs you a dynamic route on every page that reads the cookie, and a deploy to roll a flag back.
npm install -g @dif.sh/cli
dif init
Then read the SDK reference for the assign and recordExposure signatures, or the feature flags page for how a flag file becomes a rollout.