---
title: "How to add Web Experiment to a React Router app (formerly Remix)"
description: "Load Amplitude Web Experiment in a React Router (formerly Remix) app without flicker, keep variants through hydration, and confirm impressions land."
canonical: "https://amplitude.com/en-us/build/react-router-web-experiment"
---

# How to add Web Experiment to a React Router app (formerly Remix)

Load Amplitude Web Experiment in a React Router (formerly Remix) app without flicker, keep variants through hydration, and confirm impressions land.

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

---

# How to add Web Experiment to a React Router app (formerly Remix)

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

- React Router 7+
- React 19
- Framework mode
- Unified SDK 1.x
- Web Experiment script
- 15 min

Chris Tam · Last updated 14 October 2026

React Router renders each page on the server, then React hydrates it and React Router takes over navigation. [Web Experiment](https://amplitude.com/web-experimentation) lets you A/B test the headlines, buttons, and layouts on those pages from a visual editor, without a code change or a deploy.

In this tutorial, you'll load the Web Experiment script in the root `Layout`, make React elements survive hydration, and launch your first experiment. It builds on [the React Router product analytics page](/build/react-router-product-analytics).

## What you'll build

A React Router app that loads `API_KEY.experiment.js` synchronously at the top of `<head>` in the root `Layout`, applies a variant to the home page hero on the server-rendered page and again 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 React Router 7 or later app in framework mode, with Amplitude already initialized in `app/entry.client.tsx`, identity set in the root `App` component, and `reset()` called only from your sign-out form. If you don't have that yet, start with [How to add product analytics to a React Router app](/build/react-router-product-analytics). Still on Remix v2? Move to React Router first with the [Remix upgrade guide](https://reactrouter.com/upgrading/remix).
- React 19, the `create-react-router` default. The no-flicker setup depends on React 19 hydrating `document` while skipping tags a third-party script added to `<head>`. On React 18, anything the experiment script inserts into `<head>` before hydration is a hydration mismatch, and React re-renders the whole document on the client.
- The `/projects/new` route from the product analytics page. The home page's Create a project link and the Verify steps go there.
- 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, and this page doesn't use custom code actions, which need Growth or Enterprise. [Sign up for free](https://app.amplitude.com/signup) to get your project API key.

## 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 Router 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 Router 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`. The key is public and its scope is limited to the minimum needed to ingest data, so it's expected in your HTML; keep it in the env file anyway.

## 2. Load it first in the root Layout

The `Layout` export in `app/root.tsx` renders the `<html>` document around every route, on the server and in the browser, so it's the one place a tag reaches the browser ahead of everything React Router adds. Put the script at the top of `<head>`, right after `<meta charSet>` and above `<Meta />` and `<Links />`. Vite inlines `import.meta.env.VITE_AMPLITUDE_API_KEY` into the server build and the browser build alike, so the server renders the real key into the URL.

If your site needs cookie consent, configure consent handling before you add the script ([Cookies and consent management](https://amplitude.com/docs/web-experiment/cookies-and-consent-management)).

Replace only the `Layout` export and add the two constants above it. The template's `react-router` import already brings in `Links`, `Meta`, `Scripts`, and `ScrollRestoration`, so don't add a second import line.

app/root.tsx (Layout export only; keep the template's imports, links and ErrorBoundary)

```typescript
// Keep lines other React Router pages added here: the root loader with getUser and the
// identity effect in App (/build/react-router-product-analytics); the
// refetchBrowserFlags import and calls (/build/react-router-feature-flags);
// useEngagementBridge() and <FeedbackButton /> (/build/react-router-guides-surveys).
const apiKey = import.meta.env.VITE_AMPLITUDE_API_KEY;

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

export function Layout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <head>
        <meta charSet="utf-8" />
        {/* Amplitude Web Experiment. Keep it first after the charset, and synchronous. */}
        {experimentSrc ? <script src={experimentSrc} /> : null}
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <Meta />
        <Links />
      </head>
      <body>
        {children}
        <ScrollRestoration />
        <Scripts />
      </body>
    </html>
  );
}
```

**Why.** The docs ask for the script "as high as possible" in `<head>` and call the synchronous tag the best experience. In React Router that means the root `Layout`, not a route's `meta` or `links` export, which only render on the routes that define them. Don't load it from a `useEffect`, from `app/entry.client.tsx`, or with `async` or `defer`. React Router's own `<Scripts />` are module scripts that run after the browser has parsed the server HTML, often after it has painted it, so anything loaded from them shows visitors the original content before the variant replaces it. React renders a `<script src>` without `async` exactly where you put it, so it stays first in `<head>`.

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:

app/root.tsx (async alternative; replaces the \<script src> line inside \<head>)

```typescript
const antiFlicker = `
(function (d, h) {
  var apiKey = ${JSON.stringify(apiKey ?? "")};
  // 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");
      // EU projects: "https://cdn.eu.amplitude.com/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);
`;

// Inside <head>, first after <meta charSet="utf-8" />:
// {apiKey ? <script dangerouslySetInnerHTML={{ __html: antiFlicker }} /> : null}
```

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 client entry, 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. `app/entry.client.tsx` stays as it is, including the masking options from React Router session replay if you added them. That config records every session in development and about 1% in production with session replay; set `sessionReplay.sampleRate` to `0` if you don't want replays.

Check one thing that matters more for experiments than for analytics. The docs say most web experiments bucket by device ID, and `reset()` issues a new device ID. The base page's root effect returns early when there's no user, and only the sign-out form's `onSubmit` calls `reset()`. Keep it that way. If you've added a `reset()` call anywhere that runs for signed-out visitors, every anonymous visit gets a fresh device ID and a fresh roll of the dice.

### 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, right after `initAll`. Nothing else in `app/entry.client.tsx` changes, so add only these two pieces to your existing file:

app/entry.client.tsx (optional; add these lines and keep the rest of the file)

```typescript
// Keep lines other React Router pages added here: pageViews: { trackHistoryChanges:
// "pathOnly" } (/build/react-router-web-analytics); debugMode and privacyConfig
// (/build/react-router-session-replay); the amplitude-cookies import and handoff
// lines, the deviceId spread, and the experiment block
// (/build/react-router-feature-flags); the setAmplitudeReady import, the
// setAmplitudeReady(...) wrapper, and setAmplitudeReady(null)
// (/build/react-router-feature-flags, /build/react-router-guides-surveys).

// 1. Below the imports:
type WebExperimentGlobal = {
  plugin: () => Parameters<typeof amplitude.add>[0];
};

// 2. Inside the if (apiKey) branch, right after the initAll call:
// Lets On Event Tracked triggers and behavioral targeting see your events.
const webExperiment = (window as Window & { webExperiment?: WebExperimentGlobal })
  .webExperiment;
if (webExperiment) {
  amplitude.add(webExperiment.plugin());
}
```

The synchronous tag in `<head>` has already run by the time the client entry's module script executes, so `window.webExperiment` exists. With the async loader it may not have loaded yet, and the `if` skips the plugin without warning. Skip this block entirely if your experiments use the default Immediately trigger.

## 4. Make React elements survive hydration and navigation

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 Router page, the script edits the server-rendered HTML, and React then hydrates that same HTML and takes it over. 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 generated names that change between builds, such as CSS Modules hashes. A `data-` attribute you write yourself doesn't change. Add the attributes to the elements you already render in your home route, rather than replacing the file:

app/routes/home.tsx (add to your existing home route; keep its meta export)

```typescript
// Keep lines other React Router pages added here: data-guide attributes
// (/build/react-router-guides-surveys).
// Add data-exp and suppressHydrationWarning to the static elements you plan to test, for example:
<h1 data-exp="home-headline" suppressHydrationWarning>Plan your next project in minutes</h1>
<Link to="/projects/new" data-exp="home-cta">Create a project</Link>
```

If your home route doesn't import `Link` yet, add `import { Link } from "react-router";`. `/projects/new` is the route the product analytics page created. In the editor's Selector field, replace the generated path with `[data-exp="home-headline"]`. Put the attribute on DOM elements. On your own components, `data-exp` is just another prop, and it only reaches the DOM if the component spreads its remaining props onto an element; `<Link>` does.

Test content React doesn't rewrite, and tell React to accept the text. The synchronous script can change the headline before React hydrates, so React finds text in the DOM that differs from what it rendered. Without help, React treats that as a hydration mismatch, logs an error, and regenerates the tree up to the nearest `<Suspense>` boundary on the client from its own render, which with no boundary is the whole document, and that puts the original headline back. `suppressHydrationWarning` on the element you test tells React to accept the text already in the DOM for that element. It works one level deep, so put it on the element whose own text changes, and only on elements you test. Static text written into the JSX, like the headline above, is the safe target: React never writes to it again unless its props change. Text from an expression, such as `{loaderData.headline}`, belongs to React, which writes its own value back as soon as that value changes. Anything that replaces the node drops the change too: a conditional that flips, a `key` that changes, or a list that re-renders. The docs' own check is to confirm JavaScript doesn't reset your changes after they apply. Avoid Move and the remove-from-DOM Display change on server-rendered markup: React hydrates by walking the server HTML in order, so a moved or missing element triggers a mismatch that regenerates the tree, up to the nearest `<Suspense>` boundary, without your change. For content driven by data or 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 `<Link>` clicks and `navigate()`. On each change it reverts what it applied, then re-evaluates for the new URL. React Router renders the next route after its loaders finish, in a React transition, and anything inside `<Suspense>` and `<Await>` for deferred loader data, or behind client state, renders later still, so elements can appear after evaluation. If a change doesn't show after client-side navigation, set the Page's trigger to When Element Appears with the element's selector. Page triggers are in Early Access.

**Why.** On the first load, a React Router page arrives as finished HTML and the synchronous script is already running before any of React Router's scripts, so the variant can apply as soon as the element exists, with no client-side render to wait for. That's the advantage over a client-rendered app. The cost is that React owns that HTML once it hydrates, so the variant survives only where React has nothing of its own to write: static text marked with `suppressHydrationWarning`, attributes React doesn't control, and the structure it rendered on the server.

On routes that render only in the browser, such as a route with a `clientLoader` and a `HydrateFallback`, or an app with `ssr: false` in `react-router.config.ts`, React builds the content after the script runs, so use When Element Appears for anything you test there.

## 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. React Router sends neither unless you set them in a route's `headers` export, in `app/entry.server.tsx`, or your host adds them. Per the docs, two changes are required.

Add `*.amplitude.com` and `'unsafe-inline'` to your existing `script-src` directive, and to `script-src-elem` if your policy has one, so the script and the Visual Editor can load. Remove Cross-Origin-Opener-Policy, or set it to `unsafe-none`, so the Visual Editor can open your site. If you set these headers in `app/entry.server.tsx`, edit them in `handleRequest` before the response is created. This adds the two values to your existing `script-src` instead of replacing your policy:

app/entry.server.tsx (inside handleRequest, before you build the Response)

```typescript
const AMPLITUDE_SCRIPT_SOURCES = " *.amplitude.com 'unsafe-inline'";

// Add Amplitude to the script-src (and script-src-elem) you already send; don't replace the policy.
const csp = responseHeaders.get("Content-Security-Policy");
if (csp) {
  responseHeaders.set(
    "Content-Security-Policy",
    csp.replace(
      /(^|;\s*)(script-src(?:-elem)?)(?![-\w])([^;]*)/g,
      `$1$2$3${AMPLITUDE_SCRIPT_SOURCES}`,
    ),
  );
}
responseHeaders.set("Cross-Origin-Opener-Policy", "unsafe-none");
```

`handleRequest` only runs for document requests, so API and data responses are untouched. If your policy has only `default-src`, add a `script-src` directive first; this code only extends directives that exist. When a policy also has `script-src-elem`, that directive governs `<script>` tags, so the code extends both. If your policy carries a nonce, browsers ignore `'unsafe-inline'` in that directive. If you already pass a per-request nonce to `<ServerRouter nonce>` and `<Scripts nonce>`, render the same value on the experiment tag, `<script src={experimentSrc} nonce={nonce} />`, and on the inline loader if you use the async alternative, for example from a React context you provide in `app/entry.server.tsx` or a value your root loader returns. A nonced tag loads under `strict-dynamic` too, where browsers ignore host allowlists and `'unsafe-inline'`; without the nonce, neither tag loads under such a policy and nothing reports an error. The Visual Editor injects its own scripts, so it may still need `'unsafe-inline'` on the pages you edit; confirm with whoever owns your security policy before you loosen it. `unsafe-none` turns off cross-origin isolation, so if your app relies on COOP and COEP for `SharedArrayBuffer`, send it only on the pages you test. `unsafe-none` also covers guides and surveys Preview, so you don't need a second value. The editor's Mobile Simulator loads your site in an iframe, so `X-Frame-Options: DENY` or `frame-ancestors 'none'` blocks it.

## 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, then click Save and Close.
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](https://amplitude.com/docs/amplitude-ai/amplitude-mcp) 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`.

## Verify it worked

Open your home page in a normal Chrome window with ad blockers off, click Create a project and go back, then check in this order:

1. **Network tab.** Filter for `amplitude.com`. You should see `API_KEY.experiment.js` from `cdn.amplitude.com` return 200 with your real key in the URL, listed before React Router's own scripts. Then look for 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. Reload with DevTools open and check the console for a hydration error such as "Hydration failed because the server rendered text didn't match the client"; there shouldn't be one. A clean console doesn't rule out every structural problem, so also confirm the variant is still showing after the page finishes loading.
2. **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.
3. **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, and Preview Variant lets you force a variant for your own session.

## 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 the root `Layout`, right after `<meta charSet>` and above `<Meta />`, not added from a route's `links` export, a `useEffect`, `app/entry.client.tsx`, or a tag manager, and without `async` or `defer`.

### The script request goes to /script/.experiment.js, with no key

`VITE_AMPLITUDE_API_KEY` was empty when the app was built, so the `<script>` isn't rendered at all or points nowhere. Vite inlines the value at build time for the server and the browser: restart the dev server after editing `.env`, and set the variable in your build environment before `npm run build`.

### The variant flashes in, then the original comes back on page load

React regenerated the element during hydration. Either the tested element is missing `suppressHydrationWarning`, its text comes from an expression, or a Move or remove change altered the structure React expected; check the console for a hydration error. Add `suppressHydrationWarning` to the element whose text the variant changes, test static JSX, or move the test to a feature flag.

### The variant applies on the first load but not after client-side navigation

The element renders after the URL change: React Router renders the new route in a transition after its loaders finish, and `<Suspense>` boundaries and client state render later still. First confirm the selector matches the rendered element: run `document.querySelector('[data-exp="home-headline"]')` 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 variant worked yesterday and stopped after a deploy

The selector included a generated class, such as a CSS Modules hash, that changed when the stylesheet changed. 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. Reset only in the sign-out form's `onSubmit`, and keep the early return in the root effect.

### 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. The `app/entry.client.tsx` lines are the optional step 3 registration, and the `app/entry.server.tsx` lines are the step 5 headers, which you add only if you already send a CSP or COOP. 4 files, 78 lines.

4 files, 78 lines

```typescript
// -- app/root.tsx (Layout export only; keep the template's imports, links and ErrorBoundary) --
// Keep lines other React Router pages added here: the root loader with getUser and the
// identity effect in App (/build/react-router-product-analytics); the
// refetchBrowserFlags import and calls (/build/react-router-feature-flags);
// useEngagementBridge() and <FeedbackButton /> (/build/react-router-guides-surveys).
const apiKey = import.meta.env.VITE_AMPLITUDE_API_KEY;

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

export function Layout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <head>
        <meta charSet="utf-8" />
        {/* Amplitude Web Experiment. Keep it first after the charset, and synchronous. */}
        {experimentSrc ? <script src={experimentSrc} /> : null}
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <Meta />
        <Links />
      </head>
      <body>
        {children}
        <ScrollRestoration />
        <Scripts />
      </body>
    </html>
  );
}

// -- app/routes/home.tsx (add to your existing home route; keep its meta export) --
// Keep lines other React Router pages added here: data-guide attributes
// (/build/react-router-guides-surveys).
// Add data-exp and suppressHydrationWarning to the static elements you plan to test, for example:
<h1 data-exp="home-headline" suppressHydrationWarning>Plan your next project in minutes</h1>
<Link to="/projects/new" data-exp="home-cta">Create a project</Link>

// -- app/entry.client.tsx (optional; add these lines and keep the rest of the file) --
// Keep lines other React Router pages added here: pageViews: { trackHistoryChanges:
// "pathOnly" } (/build/react-router-web-analytics); debugMode and privacyConfig
// (/build/react-router-session-replay); the amplitude-cookies import and handoff
// lines, the deviceId spread, and the experiment block
// (/build/react-router-feature-flags); the setAmplitudeReady import, the
// setAmplitudeReady(...) wrapper, and setAmplitudeReady(null)
// (/build/react-router-feature-flags, /build/react-router-guides-surveys).

// 1. Below the imports:
type WebExperimentGlobal = {
  plugin: () => Parameters<typeof amplitude.add>[0];
};

// 2. Inside the if (apiKey) branch, right after the initAll call:
// Lets On Event Tracked triggers and behavioral targeting see your events.
const webExperiment = (window as Window & { webExperiment?: WebExperimentGlobal })
  .webExperiment;
if (webExperiment) {
  amplitude.add(webExperiment.plugin());
}

// -- app/entry.server.tsx (optional; inside handleRequest, before you build the Response) --
const AMPLITUDE_SCRIPT_SOURCES = " *.amplitude.com 'unsafe-inline'";

// Add Amplitude to the script-src (and script-src-elem) you already send; don't replace the policy.
const csp = responseHeaders.get("Content-Security-Policy");
if (csp) {
  responseHeaders.set(
    "Content-Security-Policy",
    csp.replace(
      /(^|;\s*)(script-src(?:-elem)?)(?![-\w])([^;]*)/g,
      `$1$2$3${AMPLITUDE_SCRIPT_SOURCES}`,
    ),
  );
}
responseHeaders.set("Cross-Origin-Opener-Policy", "unsafe-none");

// Unchanged from earlier pages; keep your versions: .env, app/routes.ts, app/routes/new-project.tsx, app/auth.server.ts, app/components/SignOutButton.tsx.
```

## 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
Complete https://amplitude.com/build/react-router-product-analytics first, or
confirm this React Router framework mode app (React 19) already has its
setup; create its app/auth.server.ts stub and app/routes/new-project.tsx
route at projects/new if they're missing. Then add Amplitude Web
Experiment by following https://amplitude.com/build/react-router-web-experiment
exactly. Merge into existing shared files and never delete lines another
Amplitude Build page added; each file's first comment on that page names
them. Load https://cdn.amplitude.com/script/<key>.experiment.js, not the
Unified Script, as a plain synchronous <script src> at the top of <head>
in the Layout export of app/root.tsx, right after <meta charSet> and
above <Meta /> and <Links />, building the URL from
import.meta.env.VITE_AMPLITUDE_API_KEY and rendering nothing when the key
is empty. Change only Layout in root.tsx; keep the loader, App, links,
ErrorBoundary and the existing react-router import. Never load the script
with async or defer, from a route's links or meta export, a useEffect,
app/entry.client.tsx, or a tag manager. Don't add a second initAll;
impressions go through the existing analytics SDK. Only if I tell you an
experiment uses On Event Tracked triggers or behavioral targeting, add
the WebExperimentGlobal type and register window.webExperiment.plugin()
with amplitude.add() right after initAll, changing nothing else in
app/entry.client.tsx. Keep reset() only in the sign-out form's onSubmit.
Add data-exp attributes and suppressHydrationWarning to the static
elements in app/routes/home.tsx that I plan to test, without replacing
the file or its meta export, and tell me which elements render text from
expressions or conditionals, since those can't be tested safely. If the
app sends a Content-Security-Policy, add *.amplitude.com and
'unsafe-inline' to the existing script-src (and script-src-elem) without
replacing the policy, and set Cross-Origin-Opener-Policy to unsafe-none.
If the app already passes a nonce to <ServerRouter> and <Scripts>, put
the same nonce on the experiment script, and tell me if the policy uses
strict-dynamic. 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 the product analytics page, shows sessions where visitors saw the treatment variant 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`, puts the winning headline in `app/routes/home.tsx` behind a flag when you want a gradual rollout, since the docs recommend moving a winner into code rather than leaving the web experiment at 100%.
- [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 frameworksHow to add feature flags and experiments to a React Router appTest changes that depend on data or state, and evaluate in a loader so the first paint already has the right variant.](/build/react-router-feature-flags)[Web frameworksHow to add session replay to a React Router appWatch the sessions behind each variant's numbers.](/build/react-router-session-replay)[Web frameworksHow to add heatmaps to a React Router appCompare where people click on the hero in control and treatment.](/build/react-router-heatmaps)

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)[Browser Unified SDK →](https://amplitude.com/docs/sdks/analytics/browser/browser-unified-sdk)[Event tracking →](https://amplitude.com/docs/feature-experiment/under-the-hood/event-tracking)[Debug analytics →](https://amplitude.com/docs/analytics/debug-analytics)[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-router-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)
