Chris Tam · Last updated 25 September 2026
Next.js is one of the most popular frameworks for building web apps. Web Experiment 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.
- 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 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://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 doeshttps://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:
NEXT_PUBLIC_AMPLITUDE_API_KEY=your-project-api-keyEU 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.
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:
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:
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 };'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:
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
Suspenseor 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
removefunction 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.comand'unsafe-inline'to your existingscript-srcdirective. 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.
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:
- In Amplitude Experiment, go to Experiments > Create Experiment > Web Experiment.
- 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.
- 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.
- Click Continue, set the Pages the experiment targets, and define your goals.
- 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.
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:
- Page source. View source on the first response. The
<script src=".../API_KEY.experiment.js">tag should sit inside<head>, with noasyncordeferattribute. If it's missing from the source and only appears later in the Elements panel, it's loading after hydration. - Network tab. Filter for
amplitude.com. You should seeAPI_KEY.experiment.jsfromcdn.amplitude.comreturn200, then the analytics SDK'sPOSTtoapi2.amplitude.com/2/httpapi. If you're bucketed into the experiment, the Instrumentation Explorer Chrome extension shows an event withevent_type$impression, the form the script sends before Amplitude transforms it. - 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 device ID or user ID. Within ten seconds to a minute the event stream shows
[Experiment] Impression, with[Experiment] Flag Keynaming your experiment and[Experiment] Variantnaming 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.
// -- .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.
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 shows the session where a visitor saw
[Experiment] Varianttreatmentand then left, so you can watch why. - Heatmaps compare where visitors click on the page in each variant, once session replay is recording.
- Feature 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 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
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.