---
title: "How to add Web Experiment to an Angular app"
description: "Load Amplitude Web Experiment in an Angular app without flicker, keep variants through hydration and navigation, and confirm impressions land."
canonical: "https://amplitude.com/en-us/build/angular-web-experiment"
---

# How to add Web Experiment to an Angular app

Load Amplitude Web Experiment in an Angular app without flicker, keep variants through hydration and navigation, and confirm impressions land.

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

---

# How to add Web Experiment to an Angular app

Load the Amplitude Web Experiment script first in \`src/index.html\` so it runs before Angular starts, give the elements you want to test stable selectors, and confirm \`\[Experiment] Impression\` events land. Works with and without Angular SSR. About 15 minutes, all copy-paste.

- Angular 19+
- Unified SDK 1.x
- Web Experiment script
- 15 min

Chris Tam · Last updated 14 October 2026

Angular renders your components in the browser, or on the server first with Angular SSR. [Web Experiment](https://amplitude.com/web-experimentation) lets you A/B test the headlines, buttons, and layouts in those components from a visual editor, without a code change or a deploy.

In this tutorial, you'll load the Web Experiment script in `src/index.html`, make Angular elements survive hydration and re-renders, and launch your first experiment. It builds on [Angular product analytics](/build/angular-product-analytics).

## What you'll build

An Angular app that loads `API_KEY.experiment.js` synchronously at the top of `src/index.html`, applies a variant to the home page hero on the first load and after Angular Router navigation, with and without Angular SSR, 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

- An Angular 19 or later app with standalone components, with Amplitude already initialized by `initAmplitude()` from `src/app/amplitude.ts` in `src/main.ts`, identity set in `AmplitudeIdentityService`, and `reset()` called only from your sign-out method. If you don't have that yet, start with [How to add product analytics to an Angular app](/build/angular-product-analytics). Angular SSR (`@angular/ssr`) is optional.
- Code uses the Angular 20 file names (`app.ts`, `App`); on Angular 19 use `app.component.ts` and `AppComponent`.
- Your Amplitude project API key, the same one in `src/environments/environment.ts`. 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.

Web Experiment availability depends on your plan, and custom code actions need Growth or Enterprise; [compare plans](https://amplitude.com/pricing). No Amplitude account yet? [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 an Angular 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 Angular 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.

## 2. Load it first in src/index.html

`src/index.html` is the page shell for every Angular build. Without SSR, it's the HTML the browser receives before any of your JavaScript runs. With Angular SSR, the server renders your routes into this same file, so the tag is in every server-rendered response too. The Angular CLI adds its stylesheets at the end of `<head>` and its scripts at the end of `<body>`, so a tag right after `<meta charset>` runs before all of them.

src/index.html (add the \<script> line; keep the rest of your index.html)

```html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <!-- Amplitude Web Experiment. Keep it first after the charset, and synchronous.
       Use the same key as amplitudeApiKey in src/environments/environment.ts.
       EU projects: https://cdn.eu.amplitude.com/script/ -->
  <script src="https://cdn.amplitude.com/script/your-project-api-key.experiment.js"></script>
  <title>MyApp</title>
  <base href="/">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <link rel="icon" type="image/x-icon" href="favicon.ico">
</head>
<body>
  <app-root></app-root>
</body>
</html>
```

Replace `your-project-api-key` with your key. The Angular CLI copies `index.html` as written and doesn't substitute environment values into it, so the key goes into the URL literally. The key is public and its scope is limited to the minimum needed to ingest data, so it's expected in your HTML, the same way it's compiled into your bundle from the environment files. If your CI writes the key into `environment.ts` before `ng build`, have it write `src/index.html` too.

**Why.** The docs ask for the script "as high as possible" in `<head>` and call the synchronous tag the best experience. Don't load it from a component, `Renderer2`, `DOCUMENT` injection, `provideAppInitializer`, or `src/main.ts`. Each of those runs after the browser has parsed and painted the page, so visitors see the original content before the variant replaces it. Don't list it under `scripts` in `angular.json` either: the CLI bundles those files into a script at the end of `<body>`. Don't add `async` or `defer` to the tag.

One key per page matters. The script reads experiments from the project in its URL, and sends impressions through the SDK that `initAll` started with `amplitudeApiKey`. If `environment.development.ts` points at a separate Amplitude project, `ng serve` loads experiments from the `index.html` project and sends impressions to the development project. Use the same key in both while you set up, or create and test experiments against a production build.

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:

src/index.html (async alternative, inside \<head>, first after the charset)

```html
<script>
  (function (d, h) {
    var apiKey = "your-project-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. Connect the script to analytics in amplitude.ts

The experiment script sends impressions through the analytics SDK you already initialize, so there's no second `initAll` and no second key. `initAmplitude()` stays in `src/main.ts`, before `bootstrapApplication`.

Add one call after `initAll`. 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. Experiments on the default **Immediately** trigger don't need it; the block below registers it whenever the script has loaded, so a later event-triggered experiment works without another code change.

This is the complete updated `src/app/amplitude.ts`. This page adds only the `WebExperimentGlobal` type and the plugin registration after `initAll`; the comment at the top names the lines other Angular pages add to this file, so keep those. If your `initAll` call is wrapped in `setAmplitudeReady(...)`, put the plugin registration after the wrapped call.

src/app/amplitude.ts

```typescript
// Keep lines other Angular pages added here: debugMode and privacyConfig
// (/build/angular-session-replay, already below); pageViews: { trackHistoryChanges: 'pathOnly' }
// (/build/angular-web-analytics); the setAmplitudeReady(...) wrapper, its import and the
// setAmplitudeReady(null) line (/build/angular-feature-flags, /build/angular-guides-surveys);
// the experiment block and any analytics.deviceId handoff (/build/angular-feature-flags).
import * as amplitude from '@amplitude/unified';
import { environment } from '../environments/environment';

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

export function initAmplitude(): void {
  if (!environment.amplitudeApiKey) {
    console.warn('amplitudeApiKey is not set; analytics is off.');
    return;
  }

  // If setAmplitudeReady(...) wraps this call, keep it and add the plugin block after it.
  void amplitude.initAll(environment.amplitudeApiKey, {
    analytics: {
      autocapture: {
        attribution: true,
        pageViews: true,
        sessions: true,
        formInteractions: true,
        fileDownloads: true,
        elementInteractions: true,
      },
    sessionReplay: {
      // Every session while you build; about 1% in production.
      sampleRate: environment.production ? 0.01 : 1,
      debugMode: !environment.production,
      // From /build/angular-session-replay. Keep your masking rules when you paste this file.
      privacyConfig: {
        maskSelector: ['[data-amp-mask]'],
        blockSelector: ['[data-amp-block]'],
        unmaskSelector: ['[data-amp-unmask]'],
      },
  });

  // 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 `main.ts` executes, so `window.webExperiment` exists. With the async loader it may not have loaded yet, and the `if` skips the plugin without warning. `initAmplitude()` runs only in the browser entry, before Angular creates its zone, so `window` is safe here and the plugin registers outside the zone like the rest of the SDK. That config records every session under `ng serve` and about 1% in a production build with session replay; set `sessionReplay.sampleRate` to `0` if you don't want replays.

Later pages in this hub show `src/app/amplitude.ts` again. If you paste one of those, keep the `WebExperimentGlobal` type and the `amplitude.add(webExperiment.plugin())` block after `initAll`.

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. `AmplitudeIdentityService` from the base page returns early when the user is null, and only `AuthService.signOut()` calls `reset()`. Keep both as they are. 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.

## 4. Make Angular elements survive hydration and re-renders

The Visual Editor records each change against a CSS selector, and the script applies it to whatever matches that selector in the live DOM. Angular owns that DOM, 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 generated names that change between builds. Angular produces two kinds: the `_ngcontent-ng-c` attribute, followed by a generated number, that emulated view encapsulation adds to every element in a component with styles, and the `_nghost-ng-c` attribute on its host. The number changes when the component changes. Development builds on Angular 19 also render `ng-reflect-` attributes that production builds don't. A `data-` attribute you write yourself doesn't change. Add a hero with `data-` attributes to `src/app/home.ts`, and keep the rest of the component:

src/app/home.ts (add to your Home component)

```typescript
// Keep lines other Angular pages added here: data-guide attributes
// (/build/angular-guides-surveys).
import { RouterLink } from '@angular/router';

@Component({
  // Add RouterLink to the component's imports.
  imports: [RouterLink],
  template: `
    <!-- Add to your template, in place of the base page's <h1>Home</h1>. -->
    <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>
      <a routerLink="/projects/new" data-exp="home-cta">Create a project</a>
    </section>
  `,
})
export class Home {}
```

If you followed [the guides and surveys page](/build/angular-guides-surveys), keep its link with `data-guide="new-project-button"`; the pin still targets it.

In the editor's Selector field, replace the generated path with `[data-exp="home-headline"]`. Put the attribute on plain HTML elements in the template. On your own component's tag, `data-exp` lands on the host element, such as `<app-hero>`, not on the markup inside it.

Test content Angular doesn't rewrite. Element changes edit an element's text or append styles to it, so the change lives on that DOM node. Static text written into the template, like the headline above, is created once and Angular never updates it, which makes it the safe target. The whole text of the element has to be static; `Hi {{ name() }}` counts as a binding. Text from a binding such as `{{ headline() }}`, `[textContent]`, or `[innerHTML]` belongs to Angular, which writes its own value back whenever the value changes, and during hydration on an SSR page. Anything that replaces the node drops the change too: an `@if` that flips, an `@for` that re-creates its items, or a route component Angular destroys and creates again. The docs' own check is to confirm JavaScript doesn't reset your changes after they apply. For content driven by data or signals, use a [feature flag](/build/angular-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 `routerLink` clicks and `router.navigate()`. On each change it reverts what it applied, then re-evaluates for the new URL. Elements inside a `@defer` block, an `@if` that waits on HTTP data, or a route animation can appear after that evaluation. If a change 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.

Without SSR, the browser receives an empty `<app-root>`, and Angular builds the hero in JavaScript after the script's first evaluation. If the variant doesn't show on first load in a non-SSR build, set the Page's trigger to **When Element Appears** with `[data-exp="home-headline"]`.

With Angular SSR, the hero arrives as finished HTML and the synchronous script is already running before any of Angular's scripts, so the variant can apply as soon as the element exists. Angular's hydration (`provideClientHydration()` in `src/app/app.config.ts`, which `ng new --ssr` adds) then reuses that same DOM instead of rebuilding it, so static text keeps the variant. Check that the provider is there: without it, Angular discards the server-rendered DOM on startup and renders the page again, without your change. Avoid **Move** and the remove-from-DOM **Display** change on server-rendered markup. Hydration expects the DOM structure the server rendered, so a moved or missing element makes Angular report a hydration error (codes `NG0500` to `NG0502`) for that component. **Visibility** hides an element without removing it, so it's safe. If you must move server-rendered markup, put `ngSkipHydration` on that component's host element so Angular renders it fresh instead of hydrating it.

If your app uses hash routing (`withHashLocation()`), 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 Matches Pattern** and a wildcard after the hash path, for example `https://your-domain.com/#/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. Angular sets neither by default. If you have them, they come from your host's configuration, a `<meta http-equiv="Content-Security-Policy">` tag in `src/index.html`, or, with Angular SSR, middleware in `src/server.ts`. Per the docs, two changes are required.

Add `*.amplitude.com` and `'unsafe-inline'` (quoted, as CSP requires) 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. `unsafe-none` also keeps [guides and surveys](/build/angular-guides-surveys) Preview working, so use it if you run both.

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.

With Angular SSR, if a middleware in `src/server.ts` (helmet, for example) already sets these headers on the Express response, add this middleware after it and above the `app.use` that calls `angularApp.handle`. It adds the two values to your existing `script-src` instead of replacing your policy:

src/server.ts (add after your header middleware, above the angularApp.handle middleware)

```typescript
// Keep lines other Angular pages added here: the flag evaluation middleware
// (/build/angular-feature-flags) and the /old-landing redirect (/build/angular-web-analytics).
const AMPLITUDE_SCRIPT_SOURCES = " *.amplitude.com 'unsafe-inline'";

app.use((req, res, next) => {
  // Add Amplitude to the script-src (and script-src-elem) you already send; don't replace the policy.
  const csp = res.getHeader('Content-Security-Policy');
  if (typeof csp === 'string') {
    res.setHeader(
      'Content-Security-Policy',
      csp.replace(
        /(^|;\s*)(script-src(?:-elem)?)(?![-\w])([^;]*)/g,
        `$1$2$3${AMPLITUDE_SCRIPT_SOURCES}`,
      ),
    );
  }
  res.setHeader('Cross-Origin-Opener-Policy', 'unsafe-none');
  next();
});
```

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. Pages you deploy as static files (an app without SSR, or a prerendered build on static hosting) don't run `server.ts`, so set their headers in your host's configuration instead. `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-inline'` means the policy no longer blocks injected inline scripts, so check with whoever owns your security policy. If your `script-src` uses a nonce (`ngCspNonce` or `CSP_NONCE`), a hash, or `strict-dynamic`, browsers ignore `'unsafe-inline'` and the host allowlists that go with them, so this setup won't load the Visual Editor. Angular's `security.autoCsp` build option writes that kind of policy, so leave it off on apps that run web experiments. 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 `ng serve` 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 **Sign in as test user**, click **Create a project** and go back to Home, then check in this order:

1. **Network tab.** Filter for `amplitude.com`. You should see `your-key.experiment.js` from `cdn.amplitude.com` return `200` with your real key in the URL, not `your-project-api-key`. Its Initiator is the document itself, not `main-*.js` or `polyfills-*.js`. 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: there shouldn't be any `NG05` hydration errors or other errors. A clean console doesn't rule out every 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 `user-42` 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, 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 `src/index.html`, right after `<meta charset>`, not added from a component, `Renderer2`, `main.ts`, the `scripts` array in `angular.json`, or a tag manager, and without `async` or `defer`.

### The script request fails with `your-project-api-key` in the URL

The script URL in `src/index.html` still says `your-project-api-key`. The CLI doesn't read your environment files into `index.html`, so paste the key into the script URL itself, and rebuild.

### The variant flashes in, then the original comes back

Angular wrote the element again. The text comes from a binding, an `@if` or `@for` re-created the node, or, with Angular SSR, `provideClientHydration()` is missing from `src/app/app.config.ts` and Angular re-rendered the whole page. Test static template text, or move the test to a feature flag.

### An `NG0500` to `NG0502` hydration error appears once the experiment runs

A **Move** or remove change altered the server-rendered structure Angular expected. Use a text, style, or **Visibility** change on that element instead, or put `ngSkipHydration` on the host element of the component you change.

### The variant doesn't show on first load in a build without SSR

Angular created the element after the script's first evaluation. Confirm the selector matches with `document.querySelector('[data-exp="home-headline"]')` in the DevTools console, then set the Page's trigger to **When Element Appears** with that selector.

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

The element renders after evaluation, behind a `@defer` block, data from an HTTP call, or a route animation. 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 an `_ngcontent-ng-c` or `_nghost-ng-c` attribute whose number changed in the new build, or an `ng-reflect-` attribute that only development builds render. Edit the change in the Visual Editor and set its selector to the `data-exp` attribute.

### An On Event Tracked trigger never fires

The experiment plugin isn't registered. Check that `src/app/amplitude.ts` calls `amplitude.add(webExperiment.plugin())` after `initAll`, and that the script tag is synchronous; with the async loader, `window.webExperiment` doesn't exist yet when `main.ts` runs.

### 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 `AuthService.signOut()`, and keep the early return for a null user in `AmplitudeIdentityService`.

### 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 this tutorial changes, in two blocks (the HTML shell, then the TypeScript): the version to hand to an agent. Files from [Angular product analytics](/build/angular-product-analytics) that this page doesn't change aren't repeated; keep your versions. It uses the synchronous tag; swap in the async alternative from step 2 only if you must. Add the step 5 middleware to `src/server.ts` only if it already sets a CSP or COOP. 3 files.

src/index.html (add the \<script> line; keep the rest of your index.html)

```html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <!-- Amplitude Web Experiment. Keep it first after the charset, and synchronous.
       Use the same key as amplitudeApiKey in src/environments/environment.ts.
       EU projects: https://cdn.eu.amplitude.com/script/ -->
  <script src="https://cdn.amplitude.com/script/your-project-api-key.experiment.js"></script>
  <title>MyApp</title>
  <base href="/">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <link rel="icon" type="image/x-icon" href="favicon.ico">
</head>
<body>
  <app-root></app-root>
</body>
</html>
```

2 files, 73 lines

```typescript
// -- src/app/amplitude.ts --
// Keep lines other Angular pages added here: debugMode and privacyConfig
// (/build/angular-session-replay, already below); pageViews: { trackHistoryChanges: 'pathOnly' }
// (/build/angular-web-analytics); the setAmplitudeReady(...) wrapper, its import and the
// setAmplitudeReady(null) line (/build/angular-feature-flags, /build/angular-guides-surveys);
// the experiment block and any analytics.deviceId handoff (/build/angular-feature-flags).
import * as amplitude from '@amplitude/unified';
import { environment } from '../environments/environment';

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

export function initAmplitude(): void {
  if (!environment.amplitudeApiKey) {
    console.warn('amplitudeApiKey is not set; analytics is off.');
    return;
  }

  // If setAmplitudeReady(...) wraps this call, keep it and add the plugin block after it.
  void amplitude.initAll(environment.amplitudeApiKey, {
    analytics: {
      autocapture: {
        attribution: true,
        pageViews: true,
        sessions: true,
        formInteractions: true,
        fileDownloads: true,
        elementInteractions: true,
      },
    sessionReplay: {
      // Every session while you build; about 1% in production.
      sampleRate: environment.production ? 0.01 : 1,
      debugMode: !environment.production,
      // From /build/angular-session-replay. Keep your masking rules when you paste this file.
      privacyConfig: {
        maskSelector: ['[data-amp-mask]'],
        blockSelector: ['[data-amp-block]'],
        unmaskSelector: ['[data-amp-unmask]'],
      },
  });

  // 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());
  }

// -- src/app/home.ts (add to your Home component) --
// Keep lines other Angular pages added here: data-guide attributes
// (/build/angular-guides-surveys).
import { RouterLink } from '@angular/router';

@Component({
  // Add RouterLink to the component's imports.
  imports: [RouterLink],
  template: `
    <!-- Add to your template, in place of the base page's <h1>Home</h1>. -->
    <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>
      <a routerLink="/projects/new" data-exp="home-cta">Create a project</a>
    </section>
  `,
})
export class Home {}

// Unchanged from earlier pages; keep your versions: src/environments/environment.ts, src/environments/environment.development.ts, src/main.ts, src/app/auth.service.ts, src/app/amplitude-identity.service.ts, src/app/app.ts (app.component.ts on Angular 19), src/app/projects/new-project.ts, src/app/app.routes.ts.
// src/app/app.config.ts with Angular SSR: keep provideClientHydration().
```

## 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
This app should already have the setup from
https://amplitude.com/build/angular-product-analytics. If src/app/amplitude.ts
doesn't exist or src/main.ts doesn't call initAmplitude() before
bootstrapApplication, follow that page first, including its environment
files, AuthService, AmplitudeIdentityService, root App component, Home and
NewProject components, and both routes. Use the root component file the app
already has (app.ts or app.component.ts); never create a second one.

Then add Amplitude Web Experiment by following
https://amplitude.com/build/angular-web-experiment exactly. In src/index.html,
add
<script src="https://cdn.amplitude.com/script/your-project-api-key.experiment.js"></script>
as the first tag after <meta charset> inside <head>, synchronous, with no
async or defer. Keep the rest of index.html as it is. Use the same key as
amplitudeApiKey in src/environments/environment.ts; if that file still says
your-project-api-key, leave that value in both and tell me where to paste my
key. Do not load the script from a component, Renderer2, main.ts,
provideAppInitializer, the scripts array in angular.json, 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.

Merge into existing files and never delete lines another Amplitude Build
page added. In src/app/amplitude.ts, keep initAmplitude() and everything
already there: sessionReplay.debugMode and privacyConfig, pageViews:
{ trackHistoryChanges: 'pathOnly' }, any setAmplitudeReady(...) wrapper
around initAll (with its import and the setAmplitudeReady(null) line),
the experiment block, and any analytics.deviceId handoff. Add the
WebExperimentGlobal type and, right after the initAll call (after the
wrapped call if it's wrapped), register window.webExperiment.plugin()
with amplitude.add() when window.webExperiment exists, as on the page.
Don't call initAll a second time.

Don't rewrite AuthService or AmplitudeIdentityService. Make sure no
amplitude.reset() call runs when the user is null; call reset() only in the
sign-out method.

In src/app/home.ts, replace the base page's <h1>Home</h1> with the page's
hero: a section with data-exp="home-hero", an h1 with
data-exp="home-headline", a p with data-exp="home-subhead", and an a with
routerLink="/projects/new" and data-exp="home-cta" (add RouterLink to the
imports), all with static text and no {{ }} bindings. Keep everything
else in the template, including any data-guide attributes the guides and
surveys page added. Add data-exp attributes to any other elements I name,
on plain HTML elements with static text, never relying on _ngcontent,
_nghost or ng-reflect attributes. Don't change new-project.ts,
app.routes.ts, the environment files or the root component. If the app uses Angular SSR, make sure
src/app/app.config.ts has provideClientHydration().

If src/server.ts already sets Content-Security-Policy or
Cross-Origin-Opener-Policy on the Express response, add the page's
middleware after that code and above the angularApp.handle middleware: add
*.amplitude.com and 'unsafe-inline' to the existing script-src directive, and
to script-src-elem if the policy has one, without replacing the rest of the
policy, and set COOP to unsafe-none. If script-src has a nonce or a hash
(ngCspNonce, CSP_NONCE, autoCsp) or strict-dynamic, stop and tell me instead
of editing it, because browsers then ignore unsafe-inline. If the headers
come from my host or a meta tag, tell me what to change instead, and tell me
that unsafe-inline weakens the policy. 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 `src/app/home.ts` 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 an Angular appTest changes that depend on data or signals, and evaluate on the server so the first paint already has the right variant.](/build/angular-feature-flags)[Web frameworksHow to add session replay to an Angular appWatch the sessions behind each variant's numbers.](/build/angular-session-replay)[Web frameworksHow to add heatmaps to an Angular appCompare where people click on the hero in control and treatment.](/build/angular-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)[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/angular-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)
