---
title: "How to add feature flags and experiments to a Next.js app"
description: "Create a deployment and flag, read the variant in a Next.js App Router client component, and track exposures that tie to the right user."
canonical: "https://amplitude.com/en-us/build/nextjs-feature-flags"
---

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

Create a deployment and flag, read the variant in a Next.js App Router client component, and track exposures that tie to the right user.

Source: https://amplitude.com/en-us/build/nextjs-feature-flags

---

# 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](https://amplitude.com/feature-management) 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](https://amplitude.com/amplitude-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](https://app.amplitude.com/signup) 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](https://amplitude.com/docs/feature-experiment/workflow/configure) 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

```sh
npm install @amplitude/unified
```

.env.local

```sh
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)

```typescript
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

```typescript
// 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

```typescript
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

```typescript
'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

```typescript
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](https://amplitude.com/docs/sdks/experiment-sdks/experiment-javascript#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

```typescript
'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

```typescript
'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

```typescript
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](#next-steps) 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

```typescript
'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](https://amplitude.com/docs/amplitude-ai/amplitude-mcp).

## 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

```typescript
// -- .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

```text
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](https://amplitude.com/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](https://amplitude.com/heatmaps) show where people click on `/pricing` in each variant, built from the session replays you record.
- [Guides and surveys](https://amplitude.com/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

[Use caseUse feature flags with server-side renderingEvaluate pricing-page-v2 in a Server Component with the Node server SDK and match the browser's identity.](/build/nextjs-feature-flags-ssr)[Web frameworksHow to add feature flags and experiments to a Node.js backendRead flags in an Express API with tight timeouts and track exposures for the same user.](/build/nodejs-feature-flags)[Web frameworksHow to add product analytics to a Next.js appTrack Plan Upgraded, the metric this experiment needs, from a server action.](/build/nextjs-product-analytics)

Reference docs

[Experiment JavaScript SDK →](https://amplitude.com/docs/sdks/experiment-sdks/experiment-javascript)[Browser Unified SDK →](https://amplitude.com/docs/sdks/analytics/browser/browser-unified-sdk)[Next.js installation guide →](https://amplitude.com/docs/sdks/frameworks/nextjs-installation-guide)[Configure your experiment (deployments) →](https://amplitude.com/docs/feature-experiment/workflow/configure)[Experiment event tracking →](https://amplitude.com/docs/feature-experiment/under-the-hood/event-tracking)[Amplitude MCP server →](https://amplitude.com/docs/amplitude-ai/amplitude-mcp)

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

For agents

[View raw .md](/build/nextjs-feature-flags.md)[llms.txt](/llms.txt)

## 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.

[Create a free account](https://app.amplitude.com/signup)[Browse all Build tutorials](https://amplitude.com/build)
