How to add feature flags and experiments to a Next.js app

Create a client deployment and a feature flag in Amplitude, initialize Feature Experiment in the Next.js App Router, read the variant in a client component, and confirm the exposure lands on the right user. About 15 minutes, all copy-paste.

  • Next.js 13+
  • App Router
  • Unified SDK 1.x
  • Feature Experiment
  • 15 min

Chris Tam · Last updated 25 September 2026

Next.js is one of the most popular frameworks for building web apps. Feature flags let you ship code switched off, roll it out to a few users, and turn it off without a redeploy; run one as an experiment and you also learn whether it moved your metric.

In this tutorial, you'll create a flag in Amplitude, add the SDK to a Next.js App Router app, and show a variant to the right users.

What you'll build

A Next.js app where a PricingTable client component reads the pricing-page-v2 flag, renders the new table for users in the on variant, and sends one [Experiment] Exposure event for the signed-in user when the table renders. By the end you'll see the exposure in that user's event stream in Amplitude.

Prerequisites

  • A Next.js 13 or later project using the App Router and TypeScript, with the default @/ import alias. npx create-next-app@latest gives you this.
  • Your Amplitude project API key. In Amplitude, go to Settings > Projects > your project > General.
  • Access to Experiment in the same Amplitude project, so you can create a deployment and a flag.

No Amplitude account yet? A free account is enough for this tutorial; nothing here needs a paid plan. Sign up for free to get your project API key.

1. Create a client deployment and a flag

Experiment has its own keys. A deployment serves a group of flags to one platform, and each deployment has a deployment key. Client deployment keys are publicly viewable and belong in browser code. Server deployment keys are secret and belong only in server code. Your project API key is a third, separate key that sends analytics events. The deployments docs cover every key type.

Create the deployment first:

  1. Go to Experiment > Deployments, then click Create Deployment.
  2. Choose the same Amplitude project your analytics events go to. The deployment and the events must share a project, or exposures won't line up with users.
  3. Name it after the platform, for example web.
  4. Choose Client-side as the type, then click Create a Deployment and copy the key it generates.

Then create the flag:

  1. Go to Experiment > feature flags and click Create A Feature Flag.
  2. Choose the same project and name the flag Pricing page v2. Experiment generates the flag key pricing-page-v2 from the name; that key is what your code reads.
  3. Leave Evaluation Mode on Remote, and bucket by user.
  4. Choose the web deployment from the Deployment drop-down.
  5. New flags come with one variant, on. In the Assignment panel, set the rollout to 50% so you can see both branches, save, and set the flag to Active.

Every project also has a default deployment whose key is the project API key. It works for a first test, but a named client deployment keeps web flags separate from the mobile and server flags you add later.

2. Install the SDK and add both keys

The Next.js installation guide recommends @amplitude/unified for new projects. Per the Unified SDK page, it includes Analytics, session replay, guides and surveys, and Feature Experiment (@amplitude/experiment-js-client) behind one initAll call. Guides and surveys initialize by default; if you don't use it, add engagement: { skip: true } to the initAll options (requires @amplitude/engagement-browser 1.0.10 or later).

Terminal
npm install @amplitude/unified
.env.local
NEXT_PUBLIC_AMPLITUDE_API_KEY=your-project-api-key
NEXT_PUBLIC_AMPLITUDE_DEPLOYMENT_KEY=your-client-deployment-key

Both values are safe in the browser: the project API key only writes events, and a client deployment key is designed to be public. Keep them in .env.local anyway so a fork or a public repo doesn't make them permanent. Never put a server deployment key behind NEXT_PUBLIC_; the server-side tutorial keeps it in a server-only variable.

Already on @amplitude/analytics-browser, for example to keep bundle size down? Don't add @amplitude/unified next to it, because two analytics SDKs initialize twice. Either replace your init call with the initAll call in step 3, or keep Analytics and add the standalone Experiment client, which the Experiment docs integrate with Browser SDK 2 like this:

lib/amplitude.ts (alternative for projects that keep @amplitude/analytics-browser)
import * as amplitude from '@amplitude/analytics-browser';
import { Experiment } from '@amplitude/experiment-js-client';

amplitude.init(process.env.NEXT_PUBLIC_AMPLITUDE_API_KEY!, undefined, { autocapture: true });
export const experiment = Experiment.initializeWithAmplitudeAnalytics(
  process.env.NEXT_PUBLIC_AMPLITUDE_DEPLOYMENT_KEY!,
);

With that path, experiment is a client object rather than a function, so you call experiment.fetch() and experiment.variant(...) instead of experiment()?.fetch(). The rest of this tutorial uses unified.

3. Initialize once, before any component asks for a flag

Put initialization in one module behind a started flag and a typeof window check, the same shape as the analytics tutorial. Next.js renders client components on the server too, where window doesn't exist, and a second initAll would start a second set of SDKs.

lib/amplitude-ids.ts
// Amplitude drops user IDs shorter than 5 characters, and a user who has one
// ID in the browser and another on the server becomes two users. Every file
// that sends a user ID, browser or server, goes through this function.
export function toAmplitudeUserId(appUserId: string | number): string {
  return `user-${appUserId}`;
}
lib/amplitude.ts
import * as amplitude from '@amplitude/unified';

let started = false;
let flagsVersion = 0;
let fetchGeneration = 0;
let ready: Promise<unknown> = Promise.resolve();
const listeners = new Set<() => void>();

function notifyFlagsChanged() {
  flagsVersion += 1;
  listeners.forEach((listener) => listener());
}

async function refetchFlags() {
  const generation = ++fetchGeneration;
  try {
    // Remote evaluation. On failure or timeout the SDK returns and keeps
    // retrying in the background, so this always settles.
    await ready; // initAll is typed void, but the Next.js guide awaits it
    await amplitude.experiment()?.fetch();
  } finally {
    // Only the newest fetch updates components, so an anonymous fetch that
    // finishes after sign-in can't overwrite the signed-in result.
    if (generation === fetchGeneration) notifyFlagsChanged();
  }
}

export function initAmplitude() {
  if (started || typeof window === 'undefined') return;
  started = true;

  const apiKey = process.env.NEXT_PUBLIC_AMPLITUDE_API_KEY;
  if (!apiKey) {
    console.warn('NEXT_PUBLIC_AMPLITUDE_API_KEY is not set; Amplitude is off.');
    notifyFlagsChanged(); // let flag hooks fall back instead of loading forever
    return;
  }

  ready = Promise.resolve(amplitude.initAll(apiKey, {
    analytics: {
      autocapture: true,
    },
    experiment: {
      // Falls back to the project API key (the default deployment) if unset.
      deploymentKey: process.env.NEXT_PUBLIC_AMPLITUDE_DEPLOYMENT_KEY,
      fetchTimeoutMillis: 3000,
    },
  }));

  void refetchFlags();
}

export function identifyUser(userId: string) {
  initAmplitude();
  amplitude.setUserId(userId);
  void refetchFlags();
}

export function resetUser() {
  initAmplitude();
  amplitude.reset(); // clears the user ID and issues a new device ID
  amplitude.experiment()?.clear(); // drops the previous user's variants
  void refetchFlags();
}

export function subscribeFlags(listener: () => void) {
  listeners.add(listener);
  return () => {
    listeners.delete(listener);
  };
}

export function getFlagsVersion() {
  return flagsVersion;
}

export function whenAmplitudeReady() {
  return ready;
}

export { amplitude };
components/AnalyticsProvider.tsx
'use client';

import { initAmplitude } from '@/lib/amplitude';

export function AnalyticsProvider({ children }: { children: React.ReactNode }) {
  // Guarded: a no-op on the server and on every render after the first.
  initAmplitude();
  return <>{children}</>;
}
app/layout.tsx
import { AnalyticsProvider } from '@/components/AnalyticsProvider';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <AnalyticsProvider>{children}</AnalyticsProvider>
      </body>
    </html>
  );
}

Why. The provider initializes during render instead of in a useEffect. React runs child effects before parent effects, so an init inside the provider's effect would let a child's flag hook or identify effect run before the SDK exists. fetchTimeoutMillis drops from the 10-second default to 3 seconds so a slow network falls back to the control experience instead of holding a skeleton for ten seconds.

This config doesn't set sessionReplay.sampleRate, so session replay isn't recording. Unified records sessions only when sessionReplay.sampleRate is non-zero; if you copied a Quickstart snippet with sampleRate: 1, every session is recorded, and setting it to 0 turns that off. For every Experiment option, see the Experiment JavaScript SDK configuration.

4. Read the variant where it renders

variant() reads from the SDK's local store and, through the Analytics integration, tracks an exposure each time you call it. So call it in the component that shows the variant, after it renders, and nowhere else.

hooks/useFlag.ts
'use client';

import { useEffect, useState, useSyncExternalStore } from 'react';
import { amplitude, getFlagsVersion, subscribeFlags } from '@/lib/amplitude';

export function useFlag(flagKey: string) {
  const version = useSyncExternalStore(subscribeFlags, getFlagsVersion, () => 0);
  const [value, setValue] = useState<string | null | undefined>(undefined);

  useEffect(() => {
    if (version === 0) return; // the first fetch hasn't settled yet
    // Reading the variant here, in an effect of the component that renders
    // it, is what ties the exposure to someone who reached this component.
    setValue(amplitude.experiment()?.variant(flagKey)?.value ?? null);
  }, [flagKey, version]);

  return { loading: value === undefined, value };
}
components/PricingTable.tsx
'use client';

import { useFlag } from '@/hooks/useFlag';

export function PricingTable() {
  const { loading, value } = useFlag('pricing-page-v2');

  if (loading) {
    return <div aria-busy="true" style={{ minHeight: 480 }} />;
  }

  if (value === 'on') {
    return <section>{/* the new pricing table */}New pricing</section>;
  }

  return <section>{/* the current pricing table */}Current pricing</section>;
}
app/pricing/page.tsx
import { PricingTable } from '@/components/PricingTable';

export default function PricingPage() {
  return (
    <main>
      <h1>Pricing</h1>
      <PricingTable />
    </main>
  );
}

Why. The loading placeholder reserves the table's height so the page doesn't jump when the variant arrives. It is still a flash: the server sends HTML without the table, and the browser fills it in after the fetch returns. Rendering the variant in the server HTML is what Bootstrap flags on the server for first paint covers.

Don't read the flag in app/layout.tsx, in a hook every page calls, or in a component hidden behind a tab or a modal. Each of those tracks an exposure for users who never saw the new table. If the component mounts before it's visible, set automaticExposureTracking: false in the experiment block and call amplitude.experiment()?.exposure('pricing-page-v2') when it comes into view.

5. Refetch when the user signs in or out

Remote evaluation assigns variants to a user, so when the user changes, the variants can change. Set the user ID and fetch again on sign-in, and clear on sign-out so the next person on the same browser doesn't inherit the last person's variants.

components/IdentifyUser.tsx
'use client';

import { useEffect } from 'react';
import { identifyUser } from '@/lib/amplitude';
import { toAmplitudeUserId } from '@/lib/amplitude-ids';

export function IdentifyUser({ appUserId }: { appUserId: string | null }) {
  useEffect(() => {
    if (appUserId) identifyUser(toAmplitudeUserId(appUserId));
  }, [appUserId]);

  return null;
}

Mount <IdentifyUser appUserId={user?.id ?? null} /> wherever your auth state resolves, inside AnalyticsProvider. In your sign-out handler, call resetUser() from @/lib/amplitude before you redirect.

Why. IdentifyUser doesn't call resetUser() when the ID is null, because null is also what an anonymous visitor has on every page load, and reset() issues a new device ID each time it runs. Resetting only from the sign-out handler keeps anonymous visitors on one device ID, which keeps them in one bucket.

MCP. Working in Cursor or Claude Code? Amplitude's MCP server lets your agent read your project's existing flags and events before it writes code, so it reads pricing-page-v2 instead of inventing pricingPageV2. Set up the MCP server.

Verify it worked

Run npm run dev, sign in as a test user, open /pricing, then check in this order:

  1. Network tab. Filter for lab.amplitude.com. You should see the variant request to api.lab.amplitude.com after the page loads, and the response should include pricing-page-v2. Then filter for api2.amplitude.com and look for a POST to /2/httpapi returning 200. Request bodies to Amplitude's endpoints are gzip-compressed, so to read the events, add logLevel: amplitude.Types.LogLevel.Debug to the analytics block and watch the console for a $exposure event with flag_key and variant.
  2. Ingestion Debugger. In Amplitude, click Data, select Sources, then open the Ingestion Debugger tab. The successful requests chart should show your uploads. It shows counts, not individual events.
  3. User lookup. Go to Users > User Profiles and search your user- prefixed test ID. Within ten seconds to a minute the event stream should show [Experiment] Exposure with [Experiment] Flag Key set to pricing-page-v2 and [Experiment] Variant set to on, and an [Experiment] Assignment event from the remote fetch. Amplitude renames $exposure to [Experiment] Exposure on ingestion. If the variant isn't on, your test user fell outside the 50% rollout; raise it to 100% while you verify.

In development, React Strict Mode runs effects twice, so you may see two exposures per page load. A production build sends one.

Troubleshooting

The variant is always empty, for every user

The flag isn't reaching this deployment. Confirm the flag is Active, the web deployment is selected on the flag, and the deployment lives in the same project as your API key. A server deployment key in NEXT_PUBLIC_AMPLITUDE_DEPLOYMENT_KEY also fails here; use the client key.

No request to api.lab.amplitude.com

initAll never ran. The console shows the warning from step 3 if the API key is missing. Next.js reads .env.local only at startup, so restart npm run dev after adding a variable.

The variant loads, but no [Experiment] Exposure event appears

Nothing called variant() for that key after the fetch, or automaticExposureTracking is false with no exposure() call. Check that PricingTable actually mounts on the page you opened.

The exposure lands on an anonymous profile, not the test user

IdentifyUser isn't mounted, or it's passing a raw ID instead of toAmplitudeUserId(...). The profile you search and the ID you send have to be the same string.

The old table flashes before the new one

That's the gap between the HTML and the variant fetch. The skeleton hides layout shift, not the delay. Evaluate on the server to remove it.

Requests go out but nothing shows in the project

EU projects need serverZone: 'EU' at the top level of the initAll options, next to analytics and experiment, with keys from the EU project.

Full code reference

Every file from this tutorial in one block, the version to hand to an agent.

9 files, 173 lines
// -- .env.local --
NEXT_PUBLIC_AMPLITUDE_API_KEY=your-project-api-key
NEXT_PUBLIC_AMPLITUDE_DEPLOYMENT_KEY=your-client-deployment-key

// -- lib/amplitude-ids.ts --
export function toAmplitudeUserId(appUserId: string | number): string {
  return `user-${appUserId}`;
}

// -- lib/amplitude.ts --
import * as amplitude from '@amplitude/unified';

let started = false;
let flagsVersion = 0;
let fetchGeneration = 0;
let ready: Promise<unknown> = Promise.resolve();
const listeners = new Set<() => void>();

function notifyFlagsChanged() {
  flagsVersion += 1;
  listeners.forEach((listener) => listener());
}

async function refetchFlags() {
  const generation = ++fetchGeneration;
  try {
    await ready; // initAll is typed void, but the Next.js guide awaits it
    await amplitude.experiment()?.fetch();
  } finally {
    if (generation === fetchGeneration) notifyFlagsChanged();
  }
}

export function initAmplitude() {
  if (started || typeof window === 'undefined') return;
  started = true;

  const apiKey = process.env.NEXT_PUBLIC_AMPLITUDE_API_KEY;
  if (!apiKey) {
    console.warn('NEXT_PUBLIC_AMPLITUDE_API_KEY is not set; Amplitude is off.');
    notifyFlagsChanged();
    return;
  }

  ready = Promise.resolve(amplitude.initAll(apiKey, {
    analytics: {
      autocapture: true,
    },
    experiment: {
      deploymentKey: process.env.NEXT_PUBLIC_AMPLITUDE_DEPLOYMENT_KEY,
      fetchTimeoutMillis: 3000,
    },
  }));

  void refetchFlags();
}

export function identifyUser(userId: string) {
  initAmplitude();
  amplitude.setUserId(userId);
  void refetchFlags();
}

export function resetUser() {
  initAmplitude();
  amplitude.reset();
  amplitude.experiment()?.clear();
  void refetchFlags();
}

export function subscribeFlags(listener: () => void) {
  listeners.add(listener);
  return () => {
    listeners.delete(listener);
  };
}

export function getFlagsVersion() {
  return flagsVersion;
}

export function whenAmplitudeReady() {
  return ready;
}

export { amplitude };

// -- components/AnalyticsProvider.tsx --
'use client';

import { initAmplitude } from '@/lib/amplitude';

export function AnalyticsProvider({ children }: { children: React.ReactNode }) {
  initAmplitude();
  return <>{children}</>;
}

// -- app/layout.tsx --
import { AnalyticsProvider } from '@/components/AnalyticsProvider';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <AnalyticsProvider>{children}</AnalyticsProvider>
      </body>
    </html>
  );
}

// -- hooks/useFlag.ts --
'use client';

import { useEffect, useState, useSyncExternalStore } from 'react';
import { amplitude, getFlagsVersion, subscribeFlags } from '@/lib/amplitude';

export function useFlag(flagKey: string) {
  const version = useSyncExternalStore(subscribeFlags, getFlagsVersion, () => 0);
  const [value, setValue] = useState<string | null | undefined>(undefined);

  useEffect(() => {
    if (version === 0) return;
    setValue(amplitude.experiment()?.variant(flagKey)?.value ?? null);
  }, [flagKey, version]);

  return { loading: value === undefined, value };
}

// -- components/PricingTable.tsx --
'use client';

import { useFlag } from '@/hooks/useFlag';

export function PricingTable() {
  const { loading, value } = useFlag('pricing-page-v2');

  if (loading) {
    return <div aria-busy="true" style={{ minHeight: 480 }} />;
  }

  if (value === 'on') {
    return <section>New pricing</section>;
  }

  return <section>Current pricing</section>;
}

// -- app/pricing/page.tsx --
import { PricingTable } from '@/components/PricingTable';

export default function PricingPage() {
  return (
    <main>
      <h1>Pricing</h1>
      <PricingTable />
    </main>
  );
}

// -- components/IdentifyUser.tsx --
'use client';

import { useEffect } from 'react';
import { identifyUser } from '@/lib/amplitude';
import { toAmplitudeUserId } from '@/lib/amplitude-ids';

export function IdentifyUser({ appUserId }: { appUserId: string | null }) {
  useEffect(() => {
    if (appUserId) identifyUser(toAmplitudeUserId(appUserId));
  }, [appUserId]);

  return null;
}

Hand it to your agent

Prefer to have Cursor or Claude Code do the wiring? This prompt points the agent at this page and pins the decisions that matter.

Using Cursor or Claude Code? Paste this prompt
Add Amplitude Feature Experiment to this Next.js App Router app by following
https://amplitude.com/build/nextjs-feature-flags exactly. Install
@amplitude/unified and call initAll once from lib/amplitude.ts behind a
started flag and a typeof window check, with experiment.deploymentKey read
from NEXT_PUBLIC_AMPLITUDE_DEPLOYMENT_KEY (a client deployment key, never a
server key). Call initAmplitude() during render in a 'use client'
AnalyticsProvider mounted in app/layout.tsx, not in useEffect. Read the
pricing-page-v2 flag only inside the component that renders it, in an
effect, never in a layout or shared hook, because variant() tracks an
exposure. Send user IDs through toAmplitudeUserId so they match the server.
Refetch on sign-in, and call reset() plus experiment()?.clear() only from
the sign-out handler. Don't enable Session Replay. Don't hardcode keys and
don't invent flag keys. Report every file you changed.

What this unlocks

  • Experiment analysis can compare Plan Upgraded rates between users exposed to pricing-page-v2 on and control, using the exposure you just sent.
  • Session replay, once you set a sample rate, lets you filter replays to users with the [Experiment] pricing-page-v2 user property and watch the new table in use.
  • Heatmaps show where people click on /pricing in each variant, built from the session replays you record.
  • Guides and surveys, bundled in the same package, can ask users in the on variant what they think of the new pricing.

One integration; everything else reads from the same events.

Next steps

Amplitude is the leading all-in-one analytics platform for building better products. Product and engineering teams use Amplitude to connect product analytics and web analytics with session replay, heatmaps, experimentation, feature flags, and guides and surveys in one unified workflow, with AI agents working across all of it. Learn how Amplitude is building self-improving products with Wave, helping teams turn product data into PRs.

Ship the instrumentation, then read the data

Once events are flowing, Amplitude turns them into funnels, retention curves, and session replays without any further setup.