---
title: "How to add Web Experiment to a React app"
description: "Load the Amplitude Web Experiment script in a Vite and React app without flicker, make React elements easy to target, and confirm impressions land."
canonical: "https://amplitude.com/en-us/build/react-web-experiment"
---

# How to add Web Experiment to a React app

Load the Amplitude Web Experiment script in a Vite and React app without flicker, make React elements easy to target, and confirm impressions land.

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

---

# 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](https://amplitude.com/web-experimentation) 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](/build/react-product-analytics).

## 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](/build/react-product-analytics).
- 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](https://app.amplitude.com/signup) to get your project API key. Custom code actions are the exception and need a Growth or Enterprise plan ([compare plans](https://amplitude.com/pricing)); 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](https://amplitude.com/docs/sdks/analytics/browser/browser-unified-sdk) 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](https://amplitude.com/docs/web-experiment/implementation) 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

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

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

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

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

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

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

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

```typescript
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](/build/react-feature-flags) 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)

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

## 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](https://amplitude.com/docs/data/chrome-extension-debug) 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

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

```text
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](https://amplitude.com/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](https://amplitude.com/heatmaps) are built from session replays, so they compare where people click on the home page hero in each variant.
- [Feature Experiment](https://amplitude.com/amplitude-experiment), already in `@amplitude/unified`, takes the winning headline into code as a flag once the test is done.
- [Guides and surveys](https://amplitude.com/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

[Web frameworksAdd feature flags and experiments to a React appTest changes that depend on state or data, where a visual edit would be overwritten.](/build/react-feature-flags)[Web frameworksAdd session replay to a React appWatch the sessions behind each variant's numbers.](/build/react-session-replay)[Web frameworksAdd Web Experiment to a Next.js appThe same setup when your React app renders on the server.](/build/nextjs-web-experiment)

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)[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/react-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)
