How to add Web Experiment to a React app

Load the Amplitude Web Experiment script in your Vite `index.html` so it runs before React renders, give the elements you want to test stable selectors, and confirm `[Experiment] Impression` events land. About 15 minutes, all copy-paste.

  • React 18+
  • Vite
  • React Router
  • Web Experiment script
  • 15 min

Chris Tam · Last updated 7 October 2026

A Vite and React app renders its whole UI in the browser. Web Experiment lets you A/B test the headlines, buttons, and layouts in that UI from a visual editor, so each test doesn't need a code change and a deploy.

In this tutorial, you'll load the Web Experiment script in index.html, make React elements easy to target, and launch your first experiment. It builds on the React product analytics page.

What you'll build

A Vite and React Router app that loads API_KEY.experiment.js synchronously at the top of index.html, applies a variant to the home page hero on first load and after client-side navigation, and sends [Experiment] Impression with [Experiment] Flag Key and [Experiment] Variant through the analytics SDK you already run. Project Created from the product analytics page becomes the experiment's goal. By the end you'll see an impression in your own user's event stream in Amplitude.

Prerequisites

  • A Vite project with React 18 or later, TypeScript, and React Router, with Amplitude analytics already initialized in src/lib/amplitude.ts. If you don't have that yet, start with Add product analytics to a React app.
  • Your Amplitude project API key. In Amplitude, go to Settings > Projects > your project > General. Web Experiment uses the same project key as your analytics.
  • Your app running 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 docs list A/B testing and the Visual Editor on all plans. Sign up for free to get your project API key. Custom code actions are the exception and need a Growth or Enterprise plan (compare plans); this page doesn't use them.

1. Pick the standalone experiment script

Web Experiment ships as a script tag, not an npm package. The Browser Unified SDK docs are explicit: @amplitude/unified from npm includes Feature Experiment, the code-based flags product, and doesn't include Web Experiment. Installing more packages won't give you the Visual Editor.

Two script tags contain Web Experiment, and on a React app with an npm-installed SDK only one is right. Implement Web Experiment documents the first, and the Unified SDK docs document the second:

  • https://cdn.amplitude.com/script/API_KEY.experiment.js loads Web Experiment only. Use it when you already initialize analytics from npm, as the React product analytics page does.
  • https://cdn.amplitude.com/script/API_KEY.js, the Unified Script, loads analytics, session replay, and Web Experiment together. Use it only on a site with no Amplitude SDK installed.

Use .experiment.js. The docs say it tracks impressions through the Browser SDK already on your site, so it needs no analytics of its own. Adding the Unified Script next to @amplitude/unified puts a second analytics SDK on the page.

The script URL takes your project API key, which you already have in .env.local:

.env.local
VITE_AMPLITUDE_API_KEY=your-project-api-key

2. Load it first in index.html

In a Vite app, index.html is the only HTML the browser receives before your JavaScript runs, so that's where the script goes. Put it at the top of <head>, before any stylesheet or module script. Vite replaces %VITE_AMPLITUDE_API_KEY% in index.html with the value from your env file when it serves or builds the page.

index.html
<!doctype html>
<html lang="en">
  <head>
    <!-- Amplitude Web Experiment. Keep it first and synchronous.
         EU projects: https://cdn.eu.amplitude.com/script/ -->
    <script src="https://cdn.amplitude.com/script/%VITE_AMPLITUDE_API_KEY%.experiment.js"></script>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>My app</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>

Why. The docs ask for the script "as high as possible" in <head> and call the synchronous tag the best experience. Don't move it into a React component, a useEffect, or src/main.tsx. Each of those runs after the browser has parsed the page and started your bundle, so visitors see the original content before the variant replaces it. Don't add async or defer to the tag either.

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

If you can't ship a blocking script

If your performance budget forbids synchronous third-party scripts, the docs offer an async loader with an anti-flicker mask. It hides the page with a style rule, loads the script asynchronously, and clears the mask when Amplitude applies changes or when the timeout expires. Use it in place of the <script src> above, in the same spot:

index.html (async alternative, inside <head>, first)
<script>
  (function (d, h) {
    var apiKey = "%VITE_AMPLITUDE_API_KEY%";
    // Milliseconds before the mask clears even if the script is slow.
    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);
</script>

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; the docs warn that tag managers load it asynchronously and cause flicker.

3. Keep analytics in the npm SDK, and keep the device ID stable

The experiment script sends impressions through the analytics SDK you already initialize, so there's no second initAll and no second key. This is src/lib/amplitude.ts from the React product analytics page, unchanged in what it does:

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

const apiKey = import.meta.env.VITE_AMPLITUDE_API_KEY;

if (apiKey) {
  amplitude.initAll(apiKey, {
    analytics: {
      autocapture: {
        attribution: true,
        pageViews: true,
        sessions: true,
        formInteractions: true,
        fileDownloads: true,
        elementInteractions: true,
      },
    },
    sessionReplay: {
      sampleRate: 1,
    },
  });
} else {
  console.warn('VITE_AMPLITUDE_API_KEY is not set; analytics is off.');
}

export { amplitude };

sessionReplay.sampleRate: 1 records every session, as on the product analytics page. Set it to 0 if you don't want replays. Keep import './lib/amplitude'; as the first line of src/main.tsx.

One change matters for experiments. The product analytics page's IdentifyUser calls amplitude.reset() whenever user is null, which is every anonymous page load, and reset() issues a new device ID. The docs say most web experiments bucket by device ID, so a visitor who isn't signed in would get a fresh roll on every visit. Reset only when someone signs out:

src/analytics/IdentifyUser.tsx
import { useEffect } from 'react';
import { amplitude } from '../lib/amplitude';

type User = { id: string; plan: string } | null;

export function IdentifyUser({ user }: { user: User }) {
  useEffect(() => {
    if (!user) return; // anonymous: keep the same device ID

    // Amplitude drops IDs shorter than 5 characters. Use the same
    // string your backend sends as user_id.
    amplitude.setUserId(`user-${user.id}`);

    const identify = new amplitude.Identify();
    identify.set('plan', user.plan);
    amplitude.identify(identify);
  }, [user?.id, user?.plan]);

  return null;
}
src/auth/signOut.ts
import { amplitude } from '../lib/amplitude';

export async function signOut() {
  // ... your sign-out logic

  amplitude.reset(); // clears the user ID and issues a new device ID
}

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 don't load together through the Unified Script, you register the experiment plugin on the analytics instance with window.amplitude.add(window.webExperiment.plugin()). With the npm SDK, make the same call on your imported instance, inside the if (apiKey) block after initAll:

src/lib/amplitude.ts (inside if (apiKey), after initAll)
type WebExperimentGlobal = { plugin: () => Parameters<typeof amplitude.add>[0] };
const webExperiment = (window as Window & { webExperiment?: WebExperimentGlobal })
  .webExperiment;
if (webExperiment) {
  amplitude.add(webExperiment.plugin());
}

The synchronous tag from step 2 has already run by the time this module executes, so window.webExperiment exists. With the async loader it may not have loaded yet, and this block skips the plugin without warning. Skip this block if your experiments use the default Immediately trigger.

4. Make React elements easy to target

The Visual Editor records each change against a CSS selector, and the script applies it to whatever matches that selector in the live DOM. On a React app that DOM is built by your components, so three things decide whether a variant sticks.

Give anything you plan to test a stable attribute. The docs warn that the editor picks the shortest unique path, which can include build-hashed class names from CSS Modules or CSS-in-JS. Those change on the next build and the selector stops matching. A data- attribute doesn't change:

src/pages/Home.tsx
import { Link } from 'react-router-dom';

export function Home() {
  return (
    <section data-exp="home-hero">
      <h1 data-exp="home-headline">Plan your next project in minutes</h1>
      <p data-exp="home-subhead">Boards, roadmaps, and templates in one place.</p>
      <Link to="/projects/new" data-exp="home-cta">
        Create a project
      </Link>
    </section>
  );
}

In the editor's Selector field, replace the generated path with [data-exp="home-headline"].

Test content React doesn't rewrite. Element changes edit an element's text or append styles to it. The script watches an element it changed and puts the variant back after a re-render, but if a component keeps rendering new text into it, from state, a fetch, or a translation that loads late, visitors can see the original text flash before the variant returns. The docs' own check is to confirm JavaScript doesn't reset your changes after they apply. Static copy, like the headline above, is the safe target; for text driven by state, use a feature flag instead.

Wait for elements that render late. The script evaluates on the first page load and every time history state is pushed or popped, which covers React Router's <Link> and navigate(). On each change it reverts what it applied, then re-evaluates for the new URL. Content behind Suspense, a lazy route, or a data fetch might not exist yet at that moment; the script waits for matching elements and applies the change when they render. If a change still doesn't show on a route like that, set the Page's trigger to When Element Appears with the element's selector. Page triggers are in Early Access.

Why. A server-rendered app sends the hero in its first HTML; a Vite app sends an empty <div id="root"> and builds the hero in JavaScript. Every Web Experiment problem specific to React comes back to that difference: selectors must survive rebuilds, changes must survive re-renders, and evaluation must find elements that didn't exist when the page loaded.

If you use HashRouter, your routes live after the #. The URL Matches targeting rule ignores hash fragments, so it can't tell /#/pricing from /#/. Target those Pages with URL Contains and the hash path, for example #/pricing.

5. Open your headers to the script and editor

Skip this step if your app doesn't send a Content Security Policy or a Cross-Origin-Opener-Policy. Vite sets neither, so if you have them, they come from your server or host configuration. Per the docs, two changes are required:

  • Content-Security-Policy: add *.amplitude.com and 'unsafe-inline' to your existing script-src directive, so the script and the Visual Editor can load.
  • Cross-Origin-Opener-Policy: remove the header, or set it to unsafe-none, so the Visual Editor can open your site.
Response headers (merge into the policy you already send)
Content-Security-Policy: script-src 'self' *.amplitude.com 'unsafe-inline'
Cross-Origin-Opener-Policy: unsafe-none

The script-src above is a minimum, not a full policy. If you load the async snippet from step 2, 'unsafe-inline' also covers that inline script.

6. Create the experiment in the Visual Editor

Run npm run dev or deploy, then create the experiment in Amplitude, following Set up a web experiment:

  1. In Amplitude Experiment, go to Experiments > Create Experiment > Web Experiment.
  2. Name the experiment, enter the URL of your home page, 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 headline, and edit its text. Open the Selector and set it to [data-exp="home-headline"]. Changes apply to the current variant as you go.
  4. Click Continue. Leave the Page targeting on your home page URL and the audience on all users.
  5. Define your goals. Choose Project Created, the event the product analytics page tracks, as the primary metric.
  6. Click Test & Preview and open the preview link to check the treatment, then 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 a goal it points at Project Created instead of inventing project_created. Set up the MCP server.

Verify it worked

Open your home page in a normal Chrome window with ad blockers off, click through to another route and back, then check in this order:

  1. Page source. View source on the page. The first tag in <head> should be the <script src> with your real API key in the URL, not the literal %VITE_AMPLITUDE_API_KEY%, and with no async or defer.
  2. Network tab. Filter for amplitude.com. You should see API_KEY.experiment.js from cdn.amplitude.com return 200 before main.tsx loads, then the analytics SDK's POST to api2.amplitude.com/2/httpapi. If you're in 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 user- prefixed test ID or your device 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 no variant? You may be in control or outside the rollout. A private window gets a new device ID and a new roll. The Amplitude Chrome extension shows page and audience targeting and exposure status for each web experiment on the page.

Troubleshooting

The original headline flashes before the variant

The script is loading late. It must be a plain <script src> at the top of <head> in index.html, not added from React, main.tsx, or a tag manager, and without async or defer.

The script request 404s with %VITE_AMPLITUDE_API_KEY% in the URL

Vite left the %...% string in place because the variable isn't set. Check the VITE_ prefix and restart the dev server, since Vite reads .env.local at startup. For production builds, set the variable in your build environment and rebuild; the key is written into index.html at build time.

The variant applies on the home page but not on a route you navigate to

The element renders after the route change, behind Suspense, a lazy import, or a fetch. First confirm the selector matches the rendered element: run document.querySelector() with your data-exp selector in the DevTools console. If it does, switch that Page's trigger to When Element Appears and select the element by its data-exp attribute.

The original text flashes before the variant shows, or the variant flickers

React re-rendered that element with its own text, and the script re-applied the change on top. Pick an element whose content doesn't come from state or a late fetch, or move the test to a feature flag.

The variant worked yesterday and stopped after a deploy

The selector included a hashed class name that changed in the new build. Edit the change in the Visual Editor and set its selector to the data-exp attribute.

Anonymous visitors see a different variant on every visit

Something calls amplitude.reset() while the user is signed out, which issues a new device ID. Remove the reset() branch from IdentifyUser and reset only in signOut().

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.

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. 6 files.

7 files, 91 lines
// -- .env.local --
VITE_AMPLITUDE_API_KEY=your-project-api-key

// -- index.html --
<!doctype html>
<html lang="en">
  <head>
    <!-- Amplitude Web Experiment. Keep it first and synchronous.
         EU projects: https://cdn.eu.amplitude.com/script/ -->
    <script src="https://cdn.amplitude.com/script/%VITE_AMPLITUDE_API_KEY%.experiment.js"></script>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>My app</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>

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

const apiKey = import.meta.env.VITE_AMPLITUDE_API_KEY;

if (apiKey) {
  amplitude.initAll(apiKey, {
    analytics: {
      autocapture: {
        attribution: true,
        pageViews: true,
        sessions: true,
        formInteractions: true,
        fileDownloads: true,
        elementInteractions: true,
      },
    },
    sessionReplay: {
      sampleRate: 1,
    },
  });
} else {
  console.warn('VITE_AMPLITUDE_API_KEY is not set; analytics is off.');
}

export { amplitude };

// -- src/analytics/IdentifyUser.tsx --
import { useEffect } from 'react';
import { amplitude } from '../lib/amplitude';

type User = { id: string; plan: string } | null;

export function IdentifyUser({ user }: { user: User }) {
  useEffect(() => {
    if (!user) return;
    amplitude.setUserId(`user-${user.id}`);
    const identify = new amplitude.Identify();
    identify.set('plan', user.plan);
    amplitude.identify(identify);
  }, [user?.id, user?.plan]);

  return null;
}

// -- src/auth/signOut.ts --
import { amplitude } from '../lib/amplitude';

export async function signOut() {
  // ... your sign-out logic
  amplitude.reset();
}

// -- src/pages/Home.tsx --
import { Link } from 'react-router-dom';

export function Home() {
  return (
    <section data-exp="home-hero">
      <h1 data-exp="home-headline">Plan your next project in minutes</h1>
      <p data-exp="home-subhead">Boards, roadmaps, and templates in one place.</p>
      <Link to="/projects/new" data-exp="home-cta">
        Create a project
      </Link>
    </section>
  );
}

// -- src/main.tsx, src/App.tsx, src/pages/NewProject.tsx --
// Unchanged from /build/react-product-analytics.

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 Web Experiment to this Vite + React Router app by following
https://amplitude.com/build/react-web-experiment exactly. Add
<script src="https://cdn.amplitude.com/script/%VITE_AMPLITUDE_API_KEY%.experiment.js"></script>
as the first tag inside <head> in index.html, synchronous, with no async
or defer. Do not load it from a React component, useEffect, main.tsx, or a
tag manager. Do not add the Unified Script (API_KEY.js) and do not
install another npm package for Web Experiment; @amplitude/unified
doesn't include it. Keep analytics in the existing src/lib/amplitude.ts
and don't call initAll a second time. Remove any amplitude.reset() call
that runs when the user is null and call reset() only in the sign-out
handler. Add data-exp attributes to the elements I name for testing. If
the app sends Content-Security-Policy or Cross-Origin-Opener-Policy
headers, add *.amplitude.com and 'unsafe-inline' to script-src and set
COOP to unsafe-none. Don't hardcode the key. Report every file you
changed.

What this unlocks

  • Session replay, on at the sample rate from step 3, shows sessions where [Experiment] Variant was treatment and Project Created never fired, so you can watch why.
  • Heatmaps are built from session replays, so they compare where people click on the home page hero in each variant.
  • Feature Experiment, already in @amplitude/unified, takes the winning headline into code as a flag once the test is done.
  • Guides and surveys can ask visitors who saw the treatment what they expected after clicking home-cta.

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.