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

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. 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. No Amplitude account yet? Sign up for free 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 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 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)
<!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)
<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
// 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)
// 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, 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 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 Preview working, so use it if you run both.

Response headers (merge into the policy you already send)
Content-Security-Policy: script-src 'self' *.amplitude.com 'unsafe-inline'
Cross-Origin-Opener-Policy: unsafe-none

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

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)
// 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:

  1. In Amplitude Experiment, go to Experiments > Create Experiment > Web Experiment.
  2. Name the experiment, enter the URL of your home page, and select your project. Amplitude uses the URL to create your first Page.
  3. When the Visual Editor opens your page, click Element Changes, click the headline, and edit its text. Open the Selector and set it to [data-exp="home-headline"]. Changes apply to the current variant as you go.
  4. Click Continue. Leave the Page targeting on your home page URL and the audience on all users.
  5. Define your goals. Choose Project Created, the event the product analytics page tracks, as the primary metric.
  6. Click Test & Preview and open the preview link to check the treatment, then start the experiment.

If Amplitude opens a Site Setup panel instead of the editor, it didn't find the script on that URL, or an ad blocker in your browser stopped it. After you save changes, allow up to 60 seconds for caches to refresh before you check the live page.

MCP. Working in Cursor or Claude Code? Amplitude's MCP server lets your agent read your project's existing event taxonomy, so when you pick a goal it points at Project Created instead of inventing project_created. Set up the MCP server.

Verify it worked

Open your home page in a normal Chrome window with ad blockers off, click 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 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 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)
<!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
// -- 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
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, 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 are built from session replays, so they compare where people click on the home page hero in each variant.
  • Feature Experiment, already in @amplitude/unified, 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 can ask visitors who saw the treatment what they expected after clicking home-cta.

One integration; everything else reads from the same events.

Next steps

Amplitude is the leading all-in-one analytics platform for building better products. Product and engineering teams use Amplitude to connect product analytics and web analytics with session replay, heatmaps, experimentation, feature flags, and guides and surveys in one unified workflow, with AI agents working across all of it. Learn how Amplitude is building self-improving products with Wave, helping teams turn product data into PRs.

Ship the instrumentation, then read the data

Once events are flowing, Amplitude turns them into funnels, retention curves, and session replays without any further setup.