---
title: "How to add Web Experiment to a Next.js app"
description: "Load the Amplitude Web Experiment script in a Next.js App Router layout without flicker, keep analytics in your npm SDK, and confirm impressions land."
canonical: "https://amplitude.com/en-us/build/nextjs-web-experiment"
---

# How to add Web Experiment to a Next.js app

Load the Amplitude Web Experiment script in a Next.js App Router layout without flicker, keep analytics in your npm SDK, and confirm impressions land.

Source: https://amplitude.com/en-us/build/nextjs-web-experiment

---

# How to add Web Experiment to a Next.js app

Load the Amplitude Web Experiment script in your Next.js root layout so variants render before the page paints, keep analytics in the npm SDK you already have, and confirm \[Experiment] Impression events land. About 15 minutes, and most of it is one \<script> tag.

- Next.js 13+
- App Router
- Web Experiment script
- 15 min

Chris Tam · Last updated 25 September 2026

Next.js is one of the most popular frameworks for building web apps. [Web Experiment](https://amplitude.com/web-experimentation) lets you A/B test headlines, images, and calls to action in a visual editor, without a code change for each test.

In this tutorial, you'll load the Web Experiment script in a Next.js App Router app without a flash of the original content, then create your first experiment.

## What you'll build

A Next.js app that loads `API_KEY.experiment.js` synchronously in the root layout's `<head>`, applies Web Experiment variants on the first load and on every client-side route change, and sends `[Experiment] Impression` with `[Experiment] Flag Key` and `[Experiment] Variant` through the analytics SDK you already run. By the end you'll see an impression in your own user's event stream in Amplitude.

## Prerequisites

- A Next.js 13 or later App Router project with Amplitude Analytics already initialized in the browser. If you don't have that yet, start with [Add analytics to your Next.js app](/build/nextjs-product-analytics).
- An Amplitude project API key. In Amplitude, go to Settings > Projects > your project > General. Web Experiment uses the same project key as your analytics.
- Your site reachable at a URL you can open in Chrome, since the Visual Editor loads your real page.

No Amplitude account yet? A free account is enough for this tutorial; the Free plan includes one active web experiment. [Sign up for free](https://app.amplitude.com/signup) to get your project API key.

## 1. Pick the standalone experiment script

Amplitude offers two script tags that both contain Web Experiment, and on a Next.js app with an npm-installed SDK only one is right. Both are covered in [Implement Web Experiment](https://amplitude.com/docs/web-experiment/implementation).

- **`https://cdn.amplitude.com/script/API_KEY.experiment.js`**: what it loads: Web Experiment only; use it when: You already initialize Analytics from npm, as the sibling Next.js page does
- **`https://cdn.amplitude.com/script/API_KEY.js` (Unified Script)**: what it loads: Analytics, session replay, and Web Experiment; use it when: You have no Amplitude SDK installed at all

Use the standalone `.experiment.js` script. The docs say it tracks impression events through the Browser SDK already installed on your site, so it needs no analytics of its own. The Unified Script includes Analytics and turns session replay on by default, so adding it next to the `@amplitude/unified` package you installed from npm puts a second analytics SDK on the page. The npm package itself doesn't include Web Experiment, which is why the script is still needed.

The npm Unified SDK, `@amplitude/unified`, doesn't include Web Experiment at all. It includes Feature Experiment, the code-based flags product. Installing it won't give you the Visual Editor.

The script URL takes your project API key, which you already expose to the browser:

.env.local

```sh
NEXT_PUBLIC_AMPLITUDE_API_KEY=your-project-api-key
```

EU projects load from `https://cdn.eu.amplitude.com/script/API_KEY.experiment.js` instead.

## 2. Load it synchronously in app/layout.tsx

Render a plain `<script src>` inside an explicit `<head>` in your root layout. The server renders it into the initial HTML, so the browser parser downloads and runs it before it paints the body, which is the synchronous load the docs describe as the best experience.

app/layout.tsx

```typescript
import type { ReactNode } from 'react';
import { AnalyticsProvider } from '@/components/AnalyticsProvider';

const apiKey = process.env.NEXT_PUBLIC_AMPLITUDE_API_KEY;

// EU projects: https://cdn.eu.amplitude.com/script/
const experimentSrc = apiKey
  ? `https://cdn.amplitude.com/script/${apiKey}.experiment.js`
  : null;

export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html lang="en">
      <head>
        {experimentSrc && (
          // Web Experiment must block first paint to avoid flicker.
          // eslint-disable-next-line @next/next/no-sync-scripts
          <script src={experimentSrc} />
        )}
      </head>
      <body>
        <AnalyticsProvider>{children}</AnalyticsProvider>
      </body>
    </html>
  );
}
```

**Why.** `next/script` can't do this job. Its default `afterInteractive` strategy injects the script after hydration starts, and `beforeInteractive`, per the Next.js docs, is preloaded early but "does not block page hydration." Either way the page paints first and the variant lands a beat later. The eslint comment is there because the `@next/next/no-sync-scripts` rule exists to stop exactly this tag; here, blocking is the point. Delete the comment and the tag and your build lint fails or your visitors see flicker.

The cost is small and documented: the base script is about 20 KB compressed plus roughly 100 bytes per flag, and the browser serves it from cache for 60 seconds across page loads. Archive finished experiments so the script doesn't keep growing.

### If you can't ship a blocking script

Some teams have a performance budget that forbids any synchronous third-party script. The docs offer an async loader with an anti-flicker mask instead: it hides the page with a style rule, loads the script asynchronously, and removes the mask when Amplitude applies changes or when the timeout expires. Render it as an inline script in the same `<head>`, in place of the `<script src>` above:

app/layout.tsx (async alternative)

```typescript
import type { ReactNode } from 'react';
import { AnalyticsProvider } from '@/components/AnalyticsProvider';

const apiKey = process.env.NEXT_PUBLIC_AMPLITUDE_API_KEY;

// Amplitude's documented anti-flicker snippet, with the key injected.
// timeout: milliseconds before the mask clears even if the script is slow.
const antiFlicker = `
(function (d, h) {
  var apiKey = ${JSON.stringify(apiKey ?? '')};
  var timeout = 1000;
  var id = "amp-exp-css";
  try {
    if (!d.getElementById(id)) {
      var st = d.createElement("style");
      st.id = id;
      st.innerText =
        "* { visibility: hidden !important; background-image: none !important; }";
      h.appendChild(st);
      window.setTimeout(function () {
        st.remove();
      }, timeout);
      var sc = d.createElement("script");
      sc.src = "https://cdn.amplitude.com/script/" + apiKey + ".experiment.js";
      sc.async = true;
      sc.onerror = function () {
        st.remove();
      };
      h.insertBefore(sc, d.currentScript || h.lastChild);
    }
  } catch (e) {
    console.error(e);
  }
})(document, document.head);
`;

export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html lang="en">
      <head>
        {apiKey && <script dangerouslySetInnerHTML={{ __html: antiFlicker }} />}
      </head>
      <body>
        <AnalyticsProvider>{children}</AnalyticsProvider>
      </body>
    </html>
  );
}
```

The trade is visible: every visitor sees a blank page for up to `timeout` milliseconds instead of a flash of the original. The synchronous tag is the better default. Don't load Web Experiment through Google Tag Manager in production; Amplitude warns that tag managers load it asynchronously and cause flicker.

## 3. Leave analytics in the npm SDK

Nothing about your analytics setup changes. The experiment script sends its impressions through the SDK you already initialize, so there's no second init call and no second project key. For completeness, this is the shape of `lib/amplitude.ts` that the layout above depends on, using `@amplitude/unified` as the other Next.js pages do:

lib/amplitude.ts

```typescript
import * as amplitude from '@amplitude/unified';

let started = false;

export function initAmplitude() {
  if (started || typeof window === 'undefined') return;
  const apiKey = process.env.NEXT_PUBLIC_AMPLITUDE_API_KEY;
  if (!apiKey) {
    console.warn('NEXT_PUBLIC_AMPLITUDE_API_KEY is not set; analytics is off.');
    return;
  }
  amplitude.initAll(apiKey, { analytics: { autocapture: true } });
  started = true;
}

export { amplitude };
```

components/AnalyticsProvider.tsx

```typescript
'use client';

import { useEffect, type ReactNode } from 'react';
import { initAmplitude } from '@/lib/amplitude';

export function AnalyticsProvider({ children }: { children: ReactNode }) {
  useEffect(() => {
    initAmplitude();
  }, []);
  return <>{children}</>;
}
```

**Why.** The `started` flag keeps a remount or Strict Mode's second effect from initializing twice, and the `typeof window` check keeps the call out of the server render.

### Optional: event-triggered pages and behavioral targeting

If an experiment uses the **On Event Tracked** page trigger or real-time behavioral targeting, the experiment client needs to hear your analytics events. The docs say that when Analytics and Web Experiment aren't loaded together through the Unified Script, you register the experiment plugin on the analytics instance. Their snippet uses the CDN global, `window.amplitude.add(window.webExperiment.plugin())`. With the npm SDK, the equivalent call is on your imported instance, after `init`:

lib/amplitude.ts (inside initAmplitude, after amplitude.init)

```typescript
const webExperiment = (window as any).webExperiment;
if (webExperiment?.plugin) {
  amplitude.add(webExperiment.plugin());
}
```

Skip this if your pages use the default **Immediately** trigger.

## 4. Let route changes re-evaluate on their own

There's no code in this step. The App Router navigates with the History API, and Web Experiment evaluates on the initial page load and every time state pushes to or pops from session history. On each change it reverts the element changes and custom code it applied, then re-evaluates and reapplies actions for the new URL. The default **Immediately** page trigger evaluates whenever the URL or route changes.

Two Next.js situations need a setting in Amplitude rather than code in your app:

- An element that renders after the route change, such as content behind `Suspense` or a client fetch, may not exist yet when evaluation runs. Set the page trigger to **When Element Appears** with the element's CSS selector. Page triggers are in Early Access.
- Custom code that injects HTML should define a `remove` function so the injected element is cleaned up when the user navigates away, since the page never fully reloads.

Target Pages by stable selectors. Next.js CSS Modules and many CSS-in-JS setups generate hashed class names that change between builds, and the Visual Editor may pick them. Add an `id` or `data-` attribute to anything you plan to test.

## 5. Open your headers to the script and editor

Skip this step if your app doesn't set a Content Security Policy or a Cross-Origin-Opener-Policy. If it does, those headers usually live in `next.config.ts` or `middleware.ts`, and two changes are required:

- **Content-Security-Policy:** add `*.amplitude.com` and `'unsafe-inline'` to your existing `script-src` directive. This lets the Web Experiment script and the Visual Editor load.
- **Cross-Origin-Opener-Policy:** remove the header, or set it to `unsafe-none`, so the Visual Editor can open your site.

next.config.ts

```typescript
import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
  async headers() {
    return [
      {
        source: '/:path*',
        headers: [
          // Add *.amplitude.com and 'unsafe-inline' to your existing script-src.
          {
            key: 'Content-Security-Policy',
            value: "script-src 'self' *.amplitude.com 'unsafe-inline'",
          },
          { key: 'Cross-Origin-Opener-Policy', value: 'unsafe-none' },
        ],
      },
    ];
  },
};

export default nextConfig;
```

Merge these values into the policy you already have rather than replacing it. The single `script-src` above is a minimum, not a full policy.

## 6. Create the experiment in the Visual Editor

Deploy, or run `npm run dev` and use the local URL, then create the experiment in Amplitude:

1. In Amplitude Experiment, go to Experiments > Create Experiment > Web Experiment.
2. Name the experiment, enter the URL of a page that loads the script, and select your project. Amplitude uses the URL to create your first Page.
3. When the Visual Editor opens your page, click **Element Changes**, click the element you want to change, and edit it. Changes apply to the current variant as you go.
4. Click **Continue**, set the Pages the experiment targets, and define your goals.
5. Start the experiment.

If Amplitude opens a Site Setup panel instead of the editor, it didn't find the script on that URL, or an ad blocker in your browser stopped it. After you save changes, allow up to 60 seconds for caches to refresh before you check the live page.

**MCP.** Working in Cursor or Claude Code? Amplitude's MCP server lets your agent read your project's existing event taxonomy, so when you pick an experiment goal it can point at an event you already track, like `Plan Upgraded`, instead of inventing a new one. [Set up the MCP server](https://amplitude.com/docs/amplitude-ai/amplitude-mcp).

## Verify it worked

Load a page the experiment targets in a normal browser window with ad blockers off, navigate to a second route and back, then check in this order:

1. **Page source.** View source on the first response. The `<script src=".../API_KEY.experiment.js">` tag should sit inside `<head>`, with no `async` or `defer` attribute. If it's missing from the source and only appears later in the Elements panel, it's loading after hydration.
2. **Network tab.** Filter for `amplitude.com`. You should see `API_KEY.experiment.js` from `cdn.amplitude.com` return `200`, then the analytics SDK's `POST` to `api2.amplitude.com/2/httpapi`. If you're bucketed into the experiment, the Instrumentation Explorer Chrome extension shows an event with `event_type` `$impression`, the form the script sends before Amplitude transforms it.
3. **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.
4. **User lookup.** Go to Users > User Profiles and search your device ID or user ID. Within ten seconds to a minute the event stream shows `[Experiment] Impression`, with `[Experiment] Flag Key` naming your experiment and `[Experiment] Variant` naming the variant you saw.

No impression and you don't see the variant? You may be in the control group or outside the rollout. Most web experiments bucket by device ID, so a private window gets a fresh device ID and a fresh roll.

## Troubleshooting

### The original content flashes before the variant

The script is loading asynchronously. Check that you used a plain `<script src>` in `<head>`, not `next/script` with any strategy, and that no tag manager loads it.

### Build fails with `@next/next/no-sync-scripts`

The lint rule is doing its job. Keep the `eslint-disable-next-line` comment on the line directly above the `<script>` tag inside the JSX expression.

### The Visual Editor opens Site Setup instead of your page

The script isn't on the URL you entered, or an ad blocker or privacy extension is blocking it. Confirm the tag in view-source on that exact URL, and turn extensions off.

### A variant applies on first load but not after navigating

The target element renders after the route change. Switch the Page's trigger to **When Element Appears**, and select the element by a stable `id` or `data-` attribute instead of a hashed class.

### Page views or sessions doubled after adding the script

You loaded the Unified Script, `API_KEY.js`, next to the npm SDK. Replace it with `API_KEY.experiment.js`.

### Script loads but no impressions arrive

Visitors with ad blockers don't log impressions or see experiments, by design. If you're not blocking anything, check that the analytics SDK initializes on every page, since the experiment script tracks impressions through it.

## Full code reference

Every file from this tutorial in one block, the version to hand to an agent. It uses the synchronous tag; swap in the async alternative from step 2 only if you must.

5 files, 83 lines

```typescript
// -- .env.local --
NEXT_PUBLIC_AMPLITUDE_API_KEY=your-project-api-key

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

const apiKey = process.env.NEXT_PUBLIC_AMPLITUDE_API_KEY;

// EU projects: https://cdn.eu.amplitude.com/script/
const experimentSrc = apiKey
  ? `https://cdn.amplitude.com/script/${apiKey}.experiment.js`
  : null;

export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html lang="en">
      <head>
        {experimentSrc && (
          // Web Experiment must block first paint to avoid flicker.
          // eslint-disable-next-line @next/next/no-sync-scripts
          <script src={experimentSrc} />
        )}
      </head>
      <body>
        <AnalyticsProvider>{children}</AnalyticsProvider>
      </body>
    </html>
  );
}

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

let started = false;

export function initAmplitude() {
  if (started || typeof window === 'undefined') return;
  const apiKey = process.env.NEXT_PUBLIC_AMPLITUDE_API_KEY;
  if (!apiKey) {
    console.warn('NEXT_PUBLIC_AMPLITUDE_API_KEY is not set; analytics is off.');
    return;
  }
  amplitude.initAll(apiKey, { analytics: { autocapture: true } });
  started = true;
}

export { amplitude };

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

import { useEffect, type ReactNode } from 'react';
import { initAmplitude } from '@/lib/amplitude';

export function AnalyticsProvider({ children }: { children: ReactNode }) {
  useEffect(() => {
    initAmplitude();
  }, []);
  return <>{children}</>;
}

// -- next.config.ts (only if you set CSP or COOP headers) --
import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
  async headers() {
    return [
      {
        source: '/:path*',
        headers: [
          {
            key: 'Content-Security-Policy',
            value: "script-src 'self' *.amplitude.com 'unsafe-inline'",
          },
          { key: 'Cross-Origin-Opener-Policy', value: 'unsafe-none' },
        ],
      },
    ];
  },
};

export default nextConfig;
```

## Hand it to your agent

Prefer to have Cursor, Claude Code, or Codex CLI 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 Web Experiment to this Next.js App Router app by following
https://amplitude.com/build/nextjs-web-experiment exactly. Load
https://cdn.amplitude.com/script/${NEXT_PUBLIC_AMPLITUDE_API_KEY}.experiment.js
as a plain synchronous <script src> inside an explicit <head> in
app/layout.tsx, with an eslint-disable-next-line comment for
@next/next/no-sync-scripts. Do not use next/script for it, do not add
async or defer, and do not load it through a tag manager. Do not add the
Unified Script (API_KEY.js). Analytics stays in the existing
lib/amplitude.ts setup (@amplitude/unified, or @amplitude/analytics-browser
if that's what the app already uses), and you must not initialize
analytics a second time. Don't hardcode the key. If the app sets
Content-Security-Policy or Cross-Origin-Opener-Policy headers, merge in
*.amplitude.com and 'unsafe-inline' for script-src and set COOP to
unsafe-none. Report every file you changed.
```

## What this unlocks

- [Session replay](https://amplitude.com/session-replay) shows the session where a visitor saw `[Experiment] Variant` `treatment` and then left, so you can watch why.
- [Heatmaps](https://amplitude.com/heatmaps) compare where visitors click on the page in each variant, once session replay is recording.
- [Feature Experiment](https://amplitude.com/amplitude-experiment) takes the winning variant into code as a flag, once the test is done and you want it in the codebase.
- [Guides and surveys](https://amplitude.com/guides-and-surveys) can ask visitors in the treatment what they thought, targeted on the same `[Experiment] Flag Key`.

One integration; everything else reads from the same events.

## Next steps

[Web frameworksHow to add product analytics to a Next.js appThe analytics setup this page depends on, with identity and a server-side event.](/build/nextjs-product-analytics)[Web frameworksAdd feature flags to a Next.js appShip the winning variant in code, behind a flag you control.](/build/nextjs-feature-flags)[Web frameworksAdd session replay to a Next.js appWatch the sessions behind each variant's numbers.](/build/nextjs-session-replay)

Reference docs

[Implement Web Experiment →](https://amplitude.com/docs/web-experiment/implementation)[Set up a web experiment →](https://amplitude.com/docs/web-experiment/set-up-a-web-experiment)[Web Experiment Pages and triggers →](https://amplitude.com/docs/web-experiment/pages)[Web Experiment actions →](https://amplitude.com/docs/web-experiment/actions)[Web Experiment event tracking →](https://amplitude.com/docs/web-experiment/tracking)[Browser Unified SDK →](https://amplitude.com/docs/sdks/analytics/browser/browser-unified-sdk)[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-web-experiment.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)
