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@latestgives 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:
- Go to Experiment > Deployments, then click Create Deployment.
- 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.
- Name it after the platform, for example
web. - Choose Client-side as the type, then click Create a Deployment and copy the key it generates.
Then create the flag:
- Go to Experiment > feature flags and click Create A Feature Flag.
- Choose the same project and name the flag
Pricing page v2. Experiment generates the flag keypricing-page-v2from the name; that key is what your code reads. - Leave Evaluation Mode on Remote, and bucket by user.
- Choose the
webdeployment from the Deployment drop-down. - 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).
npm install @amplitude/unifiedNEXT_PUBLIC_AMPLITUDE_API_KEY=your-project-api-key
NEXT_PUBLIC_AMPLITUDE_DEPLOYMENT_KEY=your-client-deployment-keyBoth 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:
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.
// 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}`;
}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 };'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}</>;
}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.
'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 };
}'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>;
}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.
'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:
- Network tab. Filter for
lab.amplitude.com. You should see the variant request toapi.lab.amplitude.comafter the page loads, and the response should includepricing-page-v2. Then filter forapi2.amplitude.comand look for aPOSTto/2/httpapireturning200. Request bodies to Amplitude's endpoints are gzip-compressed, so to read the events, addlogLevel: amplitude.Types.LogLevel.Debugto theanalyticsblock and watch the console for a$exposureevent withflag_keyandvariant. - 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.
- 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] Exposurewith[Experiment] Flag Keyset topricing-page-v2and[Experiment] Variantset toon, and an[Experiment] Assignmentevent from the remote fetch. Amplitude renames$exposureto[Experiment] Exposureon ingestion. If the variant isn'ton, 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.
// -- .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.
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 Upgradedrates between users exposed topricing-page-v2onand 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-v2user property and watch the new table in use. - Heatmaps show where people click on
/pricingin each variant, built from the session replays you record. - Guides and surveys, bundled in the same package, can ask users in the
onvariant what they think of the new pricing.
One integration; everything else reads from the same events.
Next steps
Reference docs
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.