---
title: "How to add guides and surveys to an Angular app"
description: "Show Amplitude guides and surveys in an Angular app: init from main.ts, guide links through Angular Router, stable pin targets, and a survey button."
canonical: "https://amplitude.com/en-us/build/angular-guides-surveys"
---

# How to add guides and surveys to an Angular app

Show Amplitude guides and surveys in an Angular app: init from main.ts, guide links through Angular Router, stable pin targets, and a survey button.

Source: https://amplitude.com/en-us/build/angular-guides-surveys

---

# How to add guides and surveys to an Angular app

Turn on guides and surveys in an Angular app, send guide links and buttons through Angular Router, anchor a pin to a component, and open a survey from your own button. About 20 minutes, all copy-paste.

- Angular 19+
- Unified SDK 1.x
- 20 min

Chris Tam · Last updated 14 October 2026

Angular apps move between routes without a page reload, through Angular Router. [Guides and surveys](https://amplitude.com/guides-and-surveys) walk users through those routes and ask for feedback in context, and you edit them in Amplitude instead of shipping a deploy.

In this tutorial, you'll turn on guides and surveys, route guide links through Angular Router, anchor a pin, and open a survey from a button. It builds on [the Angular product analytics page](/build/angular-product-analytics).

## What you'll build

An Angular app where guides and surveys boot once, in the browser, with the same user and device ID as your analytics, guide links marked **Use router** navigate with `Router.navigateByUrl`, a guide button runs an `open_new_project` callback in your code, a pin anchors to `[data-guide="new-project-button"]`, and a "Give feedback" button opens the survey with the key `product-feedback`. It works with and without Angular SSR. By the end you'll see the SDK report a booted user, and `Guide Viewed` and `Survey Submitted` in your own user's event stream.

## Prerequisites

- An Angular 19 or later app with standalone components, with Amplitude initialized from `src/main.ts` through `initAmplitude()` in `src/app/amplitude.ts`, and the user identified by `AmplitudeIdentityService`. If you don't have that yet, start with [How to add product analytics to an Angular app](/build/angular-product-analytics).
- Code uses the Angular 20 file names (`app.ts`, `App`); on Angular 19 use `app.component.ts` and `AppComponent`.
- Your Amplitude project API key. In Amplitude, go to Settings > Projects > your project > General. Guides and surveys must use the same key as your analytics.

Guides and surveys availability depends on your plan; [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. Install the SDK and turn guides and surveys on

[The Quickstart](https://amplitude.com/docs/get-started/amplitude-quickstart) recommends `@amplitude/unified` for browser apps installed from npm, and the [Browser Unified SDK](https://amplitude.com/docs/sdks/analytics/browser/browser-unified-sdk) includes guides and surveys and initializes them by default. If you followed the product analytics page, it's already installed, and the key is already in `src/environments/environment.ts` and `environment.development.ts`.

Terminal

```sh
npm install @amplitude/unified
```

The project API key is public and its scope is limited to the minimum needed to ingest data, so it ships in the browser bundle from the environment files, as on the product analytics page.

Then turn the product on in Amplitude. The [Guides and Surveys Web SDK docs](https://amplitude.com/docs/sdks/guides-and-surveys/sdk) say to enable guides and surveys in your project settings before anything can display, and the project-level apps setting has to include web so it appears as a target when you create a guide or survey. Open Project Settings > Guides and Surveys and turn on both.

### If your app sends security headers

Skip this if your app doesn't send a `Content-Security-Policy` or `Cross-Origin-Opener-Policy` header; `ng serve` and a new Angular SSR server send neither. If yours does, from `server.ts`, a proxy, or your host, merge in these values:

Response headers (merge into the policy you already send)

```text
Content-Security-Policy: script-src 'self' https://*.amplitude.com; connect-src 'self' https://*.amplitude.com; img-src 'self' https://*.amplitude.com; media-src 'self' https://*.amplitude.com; style-src 'self' https://*.amplitude.com 'unsafe-inline'
Cross-Origin-Opener-Policy: same-origin-allow-popups
```

The COOP line isn't an install requirement. The Testing docs list `Cross-Origin-Opener-Policy: same-origin` as a known issue for Preview mode: it blocks the messages Preview passes between Amplitude and your page, so previews never open; `same-origin-allow-popups` keeps them working. If you also run [Web Experiment](/build/angular-web-experiment), its Visual Editor needs `unsafe-none`, which works for Preview too.

The docs list the `*.amplitude.com` sources. The `'unsafe-inline'` in `style-src` is this page's choice: guides and surveys can't draw if your `style-src` blocks inline styles, and the docs' answer for strict policies is the SDK's `nonce` option (`engagement: { nonce }` in the `initAll` options), but `initAmplitude()` runs in the browser before Angular starts, so it has no per-request nonce to pass unless your server writes one somewhere the browser can read it. The dependable setup is `'unsafe-inline'` in `style-src`, as in the sample. If your security policy forbids that, have an engineer confirm a nonce setup before relying on it.

## 2. Initialize once in main.ts

Keep `initAmplitude()` where the product analytics page put it: in `src/main.ts`, before `bootstrapApplication`. The init options match [the session replay page](/build/angular-session-replay), including its masking rules, which the file below keeps. The one addition hands the `initAll` promise to a small module, so code that talks to guides and surveys can wait until it has finished. The SDK's `initAll` returns a promise, and `window.engagement` isn't ready to use until it settles.

This is the same ready module as [the Angular feature flags page](/build/angular-feature-flags). If you already have it, keep yours.

src/app/amplitude-ready.ts

```typescript
// Resolves to true once initAll has finished in the browser, or false
// if analytics is off. Nothing awaits it on the server.
let markReady: (ok: boolean) => void = () => {};
export const amplitudeReady = new Promise<boolean>((resolve) => {
  markReady = resolve;
});

export function setAmplitudeReady(init: Promise<unknown> | null): void {
  if (!init) {
    markReady(false);
    return;
  }
  init.then(
    () => markReady(true),
    (error: unknown) => {
      console.warn('[amplitude] initAll failed', error);
      markReady(false);
    },
  );
}
```

`window.engagement` isn't in TypeScript's `Window` type, and `window` doesn't exist during server rendering. Handle both in one typed accessor that lists only the methods this page calls and returns `undefined` on the server.

src/app/engagement.ts

```typescript
// The window.engagement methods this page calls, as the Guides and Surveys Web SDK docs define them.
type Engagement = {
  setRouter(fn: (url: string) => void): void;
  addCallback(name: string, fn: () => void): void;
  gs: {
    show(key: string, stepIndex?: number): void;
    closeAll(): void;
  };
  _debugStatus?: () => unknown;
};

export function getEngagement(): Engagement | undefined {
  // No window during server rendering.
  if (typeof window === 'undefined') return undefined;
  return (window as unknown as { engagement?: Engagement }).engagement;
}
```

Update `src/app/amplitude.ts` to the file below. This page changes only the `setAmplitudeReady` import and the two calls to it; the comment at the top names the lines other Angular pages add to this file, so keep those. If you already added the `setAmplitudeReady(...)` wrapper on [the feature flags page](/build/angular-feature-flags), it's the same call; keep yours.

src/app/amplitude.ts

```typescript
// Keep lines other Angular pages added here: debugMode and privacyConfig
// (/build/angular-session-replay), pageViews: { trackHistoryChanges: 'pathOnly' }
// (/build/angular-web-analytics), the experiment block and deviceId handoff
// (/build/angular-feature-flags), and the webExperiment.plugin() registration
// (/build/angular-web-experiment).
import * as amplitude from '@amplitude/unified';
import { environment } from '../environments/environment';
import { setAmplitudeReady } from './amplitude-ready';

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

  // initAll starts analytics, session replay, Feature Experiment, and
  // guides and surveys. Guides and surveys boot with the analytics user.
  setAmplitudeReady(
    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]'],
        },
    }),
  );
}
```

`src/main.ts` doesn't change: it still calls `initAmplitude()` before `bootstrapApplication`.

**Why.** With the Unified SDK, guides and surveys initialize alongside analytics and boot with the same user and session. Don't call `engagement.init()` or `engagement.boot()` yourself, and don't initialize from a component, a service, or `provideAppInitializer`. The docs say calling `boot` more than once causes unexpected behavior, especially for guides and surveys set to appear immediately. `main.ts` runs once per page load, only in the browser (Angular SSR renders from `main.server.ts`), and before bootstrap creates the Angular zone, which the Browser SDK docs ask Zone.js apps to stay outside of. `initAmplitude()` doesn't return the `initAll` promise, so bootstrap doesn't wait for guides and surveys to load. `amplitudeReady` resolves to `false` when the key is missing or `initAll` fails, so nothing waits forever, and a failure logs `[amplitude] initAll failed` in the console.

Session replay keeps the product analytics page's rate: every session under `ng serve`, about 1% in a production build, which is where Amplitude recommends starting. `debugMode`, also from the session replay page, adds debug information and ignores the focus check so replays record with DevTools open; it's off in production builds. Set `sampleRate` to `0` to turn session replay off. Don't pass `engagement: { skip: true }`, which stops guides and surveys from loading at all.

The accessor doesn't augment the global `Window` type, so it can't conflict with any declaration the SDK packages ship. Every file below calls `getEngagement()` instead of `window.engagement`.

## 3. Connect guide buttons to Angular Router

A guide's **Visit link** action reloads the page unless you give the SDK a router. A **Run callback** action does nothing unless your code has registered a function with that name. The Guides and Surveys Web SDK docs show the Angular version: inject `Router` and pass `setRouter` a function that calls `navigateByUrl`. This service does that once the SDK is ready, and registers the callback too:

src/app/engagement-bridge.service.ts

```typescript
import { Injectable, NgZone, PLATFORM_ID, inject } from '@angular/core';
import { HashLocationStrategy, LocationStrategy, isPlatformBrowser } from '@angular/common';
import { Router } from '@angular/router';
import { amplitudeReady } from './amplitude-ready';
import { getEngagement } from './engagement';

@Injectable({ providedIn: 'root' })
export class EngagementBridgeService {
  private readonly router = inject(Router);
  private readonly zone = inject(NgZone);
  private readonly locationStrategy = inject(LocationStrategy);
  private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));

  constructor() {
    // No guides or surveys during server rendering.
    if (!this.isBrowser) return;

    void amplitudeReady.then((ok) => {
      // Analytics is off or initAll failed.
      if (!ok) return;

      const engagement = getEngagement();
      if (!engagement) {
        console.warn('window.engagement not found; guide links will reload the page.');
        return;
      }

      // Guide links set to "Use router" call this instead of reloading.
      engagement.setRouter((url) => {
        const target = new URL(url, window.location.href);
        const appUrl = this.toAppUrl(target);

        // Other sites, and paths outside the app's base href, load normally.
        if (appUrl === null) {
          window.location.assign(target.href);
          return;
        }

        this.navigate(appUrl);
      });

      // Guide buttons with "Run callback" and the name open_new_project.
      engagement.addCallback('open_new_project', () => this.navigate('/projects/new'));
    });
  }

  // The SDK calls these functions outside the Angular zone.
  private navigate(url: string): void {
    this.zone.run(() => void this.router.navigateByUrl(url));
  }

  // The router URL for a link inside this app, or null for anything else.
  private toAppUrl(target: URL): string | null {
    if (target.origin !== window.location.origin) return null;

    // withHashLocation(): the route is everything after the #.
    if (this.locationStrategy instanceof HashLocationStrategy) {
      return target.hash ? target.hash.slice(1) || '/' : target.pathname + target.search;
    }

    // Drop the <base href> so the router doesn't add it a second time.
    const base = this.locationStrategy.getBaseHref().replace(/\/$/, '');
    let path = target.pathname;
    if (base) {
      if (path !== base && !path.startsWith(`${base}/`)) return null;
      path = path.slice(base.length) || '/';
    }
    return path + target.search + target.hash;
  }
```

Create it from the root component's constructor, next to `AmplitudeIdentityService`. Step 6 shows everything this page adds to `src/app/app.ts`; the lines from this step are:

src/app/app.ts (excerpt)

```typescript
// Angular 19 and earlier: src/app/app.component.ts, class AppComponent.
import { EngagementBridgeService } from './engagement-bridge.service';

export class App {
  constructor() {
    inject(AmplitudeIdentityService);
    // Registers guide links and Run callback buttons once, in the browser.
    inject(EngagementBridgeService);
  }
```

Then, in the builder, set the URL behavior on every **Visit link** action to **Use router**, and enter `open_new_project` as the name on any **Run callback** action. The docs are explicit: **Same tab** and **New tab** ignore the router you set, and the callback name must match the string passed to `addCallback`.

**Why.** The docs register `setRouter` in the root component's constructor (or an initializer). A root service created from that constructor runs once per page load and lives as long as the app, so the router and callback stay registered on every route. Four things change from the docs' example. The `isPlatformBrowser` check keeps the service idle during server rendering, where there's no `window.engagement`. Waiting for `amplitudeReady` means `window.engagement` exists when `setRouter` runs; the docs' example assumes it already does. `NgZone.run` matters because `initAmplitude()` started the SDK outside the Angular zone, so its click handlers call your router function outside it too; without `zone.run`, a Zone.js app logs "Navigation triggered outside Angular zone" and can skip change detection after the navigation, and in a zoneless app `zone.run` simply calls the function. The URL handling is the fourth change. The docs say `navigateByUrl` accepts a full URL string, but it treats its argument as a path inside the app, so the service converts the guide's URL: it sends other origins through a normal page load, strips your `<base href>` (an app built with `--base-href /app/` would otherwise get `/app` twice), and with `withHashLocation()` takes the route from after the `#`. With hash routing, enter guide URLs as `https://your-domain.com/#/projects/new`.

## 4. Keep identity stable so guides show once

Amplitude remembers which guides and surveys a user has seen, completed, or dismissed as part of that user's history. The docs warn that if a user's identity changes between visits, Amplitude treats them as new and shows the experience again. `amplitude.reset()` issues a new device ID, so calling it whenever the user is `null`, which is every anonymous page load, would show an anonymous visitor the same welcome guide on every visit.

`AmplitudeIdentityService` from the product analytics page already returns early when there's no user, so keep it as it is. Reset only from the sign-out method, and close anything still on screen first:

src/app/auth.service.ts

```typescript
// Keep lines other Angular pages added here: experiment()?.clear() and
// refetch() after reset(), and the FlagsService import and inject() field
// (/build/angular-feature-flags).
import { Injectable, signal } from '@angular/core';
import * as amplitude from '@amplitude/unified';
import { getEngagement } from './engagement';

export interface User {
  id: string;
  plan?: string;
}

// Stand-in for your auth. Replace the bodies with your own logic.
@Injectable({ providedIn: 'root' })
export class AuthService {
  readonly user = signal<User | null>(null);

  signIn(user: User): void {
    // ... your sign-in logic
    this.user.set(user);
  }

  async signOut(): Promise<void> {
    // ... your sign-out logic
    this.user.set(null);
    getEngagement()?.gs.closeAll(); // close anything still on screen
    // Clears the user ID and issues a new device ID for the next person.
    amplitude.reset();
  }
```

If you keep your own auth, add the `closeAll()` line before `amplitude.reset()` in your sign-out method. If you followed the feature flags page, keep its `FlagsService` injection and the `amplitude.experiment()?.clear()` and `void this.flags.refetch()` lines after `reset()`; this page only adds `closeAll()` before it.

**Why.** Guides and surveys read the same user as analytics, so targeting sees the user ID and `plan` that `AmplitudeIdentityService` sets. The default bucketing unit for rollouts is User ID, so set the user ID before you rely on a percentage rollout.

## 5. Give pins a target that survives a rebuild

Pins, tooltips, and card embeds attach to an element by CSS selector, and the docs recommend data attributes added specifically for guide targeting. Angular's default view encapsulation adds a generated `_ngcontent-ng-c` attribute, followed by a number, to every element in a component that has styles, and a selector the visual picker builds from them can change on the next build, and then the pin stops rendering. Tag the elements you plan to point at. Add a link with a `data-guide` attribute 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-exp attributes
// (/build/angular-web-experiment).
import { RouterLink } from '@angular/router';

@Component({
  // Add RouterLink to the component's imports.
  imports: [RouterLink],
  template: `
    <!-- Add to your existing template. -->
    <a routerLink="/projects/new" data-guide="new-project-button">New project</a>
  `,
})
export class Home {}
```

The `data-guide` attribute stays on the `<a>` that `routerLink` enhances, so the selector matches the link itself. In the guide builder, add a Pin step and enter `[data-guide="new-project-button"]` as its CSS selector instead of the generated one.

If you followed [the Web Experiment page](/build/angular-web-experiment), add `data-guide="new-project-button"` to its **Create a project** link instead of adding a second link, and keep its `data-exp` attributes.

**Why.** The docs say that if the target element isn't in the DOM when the step renders, the pin doesn't display, and the SDK doesn't move it to another position. In an Angular app, anything inside a `@defer` block, behind an `@if` that waits for data, or in a lazy-loaded route (`loadComponent`) can render after the guide triggers. For those, set the trigger to **When element appears** with the same selector, so the guide fires when the element renders rather than when the page loads. On a later step in a tour, turn on **Skip if target not found**, which moves on if the target doesn't appear within 250 ms.

## 6. Open a survey from your own button

When a survey should open because someone clicked something, the docs recommend setting its trigger to **None** in the builder and calling `show` from the click handler.

src/app/feedback-button.ts

```typescript
import { Component } from '@angular/core';
import { getEngagement } from './engagement';

@Component({
  selector: 'app-feedback-button',
  template: `<button type="button" (click)="openFeedback()">Give feedback</button>`,
})
export class FeedbackButton {
  protected openFeedback(): void {
    getEngagement()?.gs.show('product-feedback');
  }
```

Render it in the root component, next to the identity service from the product analytics page and the bridge from step 3. These are all the lines this page adds to `src/app/app.ts`; keep the rest of the file:

src/app/app.ts (add to your root component)

```typescript
// Angular 19 and earlier: src/app/app.component.ts, class AppComponent.
// Keep lines other Angular pages added here: nav links (/build/angular-session-replay,
// /build/angular-heatmaps), ServerFlagsService (/build/angular-feature-flags).
import { EngagementBridgeService } from './engagement-bridge.service';
import { FeedbackButton } from './feedback-button';

@Component({
  // Add FeedbackButton to your existing imports array.
  imports: [RouterOutlet, RouterLink, FeedbackButton],
  template: `
    <nav>
      <!-- Keep your existing links and sign-in buttons; add: -->
      <app-feedback-button />
    </nav>
    <router-outlet />
  `,
})
export class App {
  constructor() {
    inject(AmplitudeIdentityService);
    // Registers guide links and Run callback buttons once, in the browser.
    inject(EngagementBridgeService);
  }
```

Replace `product-feedback` with your survey's key from the builder. A click before `initAll` has finished does nothing, because `getEngagement()` is still `undefined`; click again once the page has loaded. `show` ignores targeting rules and limits but still respects page targeting, so the survey's Where settings must allow every route where the button renders. Because the button lives in the root component, that's every route in the app.

If you'd rather use the **On event tracked** trigger, the event has to fire in the browser. `Project Created`, tracked with `amplitude.track` from the click handler on the product analytics page, works. An event your backend sends never reaches the guides and surveys SDK; call `show` from the client after that request resolves instead.

**MCP.** Working in Cursor or Claude Code? Amplitude's MCP server includes `list_guides_surveys` and `get_guide_or_survey`, which list your project's guides and surveys and return each one's page targeting and trigger configuration, so your agent can check that the key it wires into `show` exists and is allowed on that route. [Set up the MCP server](https://amplitude.com/docs/amplitude-ai/amplitude-mcp).

## Verify it worked

Run `ng serve` and click **Sign in as test user**. In Amplitude, create a guide with a Pin step on `[data-guide="new-project-button"]`, set to **Open by default**, and a button whose action is **Run callback** with `open_new_project`, and a survey with the key `product-feedback` and the trigger **None**. Add `user-42` under **Test users** and set both to **Testing**, or click **Preview** and enter `http://localhost:4200`. Then, in the app, open the pin, click its button, and submit the survey from **Give feedback**. Check in this order:

1. **Network tab.** Filter for `amplitude.com`. You should see requests to `gs.amplitude.com`, the default guides and surveys host, and the analytics SDK's `POST` to `api2.amplitude.com/2/httpapi` returning `200`. With Angular SSR, none of them start until the page loads in the browser; the server render sends nothing.
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`. Within ten seconds to a minute the event stream shows `Guide Viewed` for the pin (a pin that isn't open by default skips `Guide Viewed` when someone clicks its target), and `Survey Viewed` and `Survey Submitted` for the survey you answered. Guides and surveys events don't count toward your event volume.
4. **Browser console.** `window.engagement` returns an object, not `undefined`. Run `window.engagement._debugStatus()`: `user.user_id` should be `user-42`, `apiKey` should be set, `stateInitialized` and `decideSuccessful` should be `true`, and `num_guides_surveys` should be non-zero on the home page.
5. **Amplitude Chrome extension.** The Guides & Surveys tab shows four setup checks: **Installed**, **Initialized**, **Analytics Connected**, and **Booted**. Let the page finish loading first, since the tab doesn't update live.
6. **In the app.** The pin appeared on **New project**, its button navigated to `/projects/new` without a document request in the Network tab and with no "Navigation triggered outside Angular zone" warning in the console, and **Give feedback** opened the survey.

A guide you already completed or dismissed won't show again. Open your user profile, go to the Guides or Surveys tab, and select **Clear history** for that experience, or click **Reset User History** in the preview bar.

## Troubleshooting

### `window.engagement` is undefined

Guides and surveys never initialized. Confirm `src/main.ts` calls `initAmplitude()` before `bootstrapApplication`, that `amplitudeApiKey` is set in the environment file your configuration reads (`environment.development.ts` under `ng serve`), and that nothing passes `engagement: { skip: true }`.

### "window is not defined" during server rendering

Something reads `window.engagement` on the server, such as a call in a constructor, a field initializer, or `ngOnInit`. Use `getEngagement()`, which returns `undefined` on the server, and call it from a click handler or from a service behind the `isPlatformBrowser` check.

### A guide set to Immediately shows twice, or not at all

Something calls `engagement.boot()` or `engagement.init()` on top of `initAll`, or `initAmplitude()` runs from a component or service as well as from `main.ts`. Delete those calls and keep the one in `main.ts`.

### Anonymous visitors see the same welcome guide on every visit

`amplitude.reset()` runs while the user is signed out and issues a new device ID, so every visit looks like a new user. Keep the early return in `AmplitudeIdentityService` and call `reset()` only in `signOut()`.

### The pin never appears, but the survey button works

The target element isn't in the DOM when the step renders, or the selector no longer matches. Check `document.querySelector('[data-guide="new-project-button"]')` in the console, and for elements inside `@defer`, `@if`, or a lazy route, use the **When element appears** trigger.

### Guide links reload the whole page

The link's URL behavior isn't **Use router**, `EngagementBridgeService` is never injected in the `App` constructor, or the console shows `window.engagement not found`. Run `Object.keys(window.engagement._.callbacks)` in the console; `engagement-router` should be in the list once `setRouter` has run.

### A guide button changes the URL but the page doesn't update

The console shows "Navigation triggered outside Angular zone". The router call isn't wrapped in `NgZone.run`, for example because you pasted the docs' constructor example as it is. Use the service from step 3, which navigates through `this.zone.run`.

### A Run callback button does nothing

The name in the builder doesn't match the `addCallback` string. Run `Object.keys(window.engagement._.callbacks)` in the console; `open_new_project` should be in the list, next to internal entries like `engagement-router`.

### Guide links reload and the console shows `[amplitude] initAll failed`

`initAll` rejected, so `amplitudeReady` resolved to `false` and the bridge never registered. A blocked guides and surveys or session replay script, for example from an ad blocker, can cause this. Allow the Amplitude hosts, or test in a browser profile without extensions.

### A guide shows on some pages but not on the same page with a trailing slash

Where rules match the exact URL, including trailing slashes. Write the rule to match the URLs your server or host actually serves, or use **URL/screen starts with**.

### Preview never opens on your page

Your app sends `Cross-Origin-Opener-Policy: same-origin`, which blocks the messages Preview mode depends on. Set it to `same-origin-allow-popups`, as in step 1, or to `unsafe-none` if you also run Web Experiment.

## Full code reference

Every file this tutorial adds or changes, in one block: the version to hand to an agent. 8 files. Files from [the product analytics page](/build/angular-product-analytics) that this page doesn't change aren't repeated; keep your versions, including the ones other Angular pages changed. For `app.ts` and `home.ts`, add the lines shown to your existing component instead of replacing it.

8 files, 233 lines

```typescript
// -- src/app/amplitude-ready.ts --
// Resolves to true once initAll has finished in the browser, or false
// if analytics is off. Nothing awaits it on the server.
let markReady: (ok: boolean) => void = () => {};
export const amplitudeReady = new Promise<boolean>((resolve) => {
  markReady = resolve;
});

export function setAmplitudeReady(init: Promise<unknown> | null): void {
  if (!init) {
    markReady(false);
    return;
  }
  init.then(
    () => markReady(true),
    (error: unknown) => {
      console.warn('[amplitude] initAll failed', error);
      markReady(false);
    },
  );
}

// -- src/app/engagement.ts --
type Engagement = {
  setRouter(fn: (url: string) => void): void;
  addCallback(name: string, fn: () => void): void;
  gs: {
    show(key: string, stepIndex?: number): void;
    closeAll(): void;
  };
  _debugStatus?: () => unknown;
};

export function getEngagement(): Engagement | undefined {
  if (typeof window === 'undefined') return undefined;
  return (window as unknown as { engagement?: Engagement }).engagement;
}

// -- src/app/amplitude.ts --
// Keep lines other Angular pages added here: debugMode and privacyConfig
// (/build/angular-session-replay), pageViews: { trackHistoryChanges: 'pathOnly' }
// (/build/angular-web-analytics), the experiment block and deviceId handoff
// (/build/angular-feature-flags), and the webExperiment.plugin() registration
// (/build/angular-web-experiment).
import * as amplitude from '@amplitude/unified';
import { environment } from '../environments/environment';
import { setAmplitudeReady } from './amplitude-ready';

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

  // initAll starts analytics, session replay, Feature Experiment, and
  // guides and surveys. Guides and surveys boot with the analytics user.
  setAmplitudeReady(
    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]'],
        },
    }),
  );
}

// -- src/app/engagement-bridge.service.ts --
import { Injectable, NgZone, PLATFORM_ID, inject } from '@angular/core';
import { HashLocationStrategy, LocationStrategy, isPlatformBrowser } from '@angular/common';
import { Router } from '@angular/router';
import { amplitudeReady } from './amplitude-ready';
import { getEngagement } from './engagement';

@Injectable({ providedIn: 'root' })
export class EngagementBridgeService {
  private readonly router = inject(Router);
  private readonly zone = inject(NgZone);
  private readonly locationStrategy = inject(LocationStrategy);
  private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));

  constructor() {
    if (!this.isBrowser) return;

    void amplitudeReady.then((ok) => {
      if (!ok) return;

      const engagement = getEngagement();
      if (!engagement) {
        console.warn('window.engagement not found; guide links will reload the page.');
        return;
      }

      engagement.setRouter((url) => {
        const target = new URL(url, window.location.href);
        const appUrl = this.toAppUrl(target);

        if (appUrl === null) {
          window.location.assign(target.href);
          return;
        }

        this.navigate(appUrl);
      });

      engagement.addCallback('open_new_project', () => this.navigate('/projects/new'));
    });
  }

  private navigate(url: string): void {
    this.zone.run(() => void this.router.navigateByUrl(url));
  }

  private toAppUrl(target: URL): string | null {
    if (target.origin !== window.location.origin) return null;

    if (this.locationStrategy instanceof HashLocationStrategy) {
      return target.hash ? target.hash.slice(1) || '/' : target.pathname + target.search;
    }

    const base = this.locationStrategy.getBaseHref().replace(/\/$/, '');
    let path = target.pathname;
    if (base) {
      if (path !== base && !path.startsWith(`${base}/`)) return null;
      path = path.slice(base.length) || '/';
    }
    return path + target.search + target.hash;
  }

// -- src/app/auth.service.ts --
// Keep lines other Angular pages added here: experiment()?.clear() and
// refetch() after reset(), and the FlagsService import and inject() field
// (/build/angular-feature-flags).
import { Injectable, signal } from '@angular/core';
import * as amplitude from '@amplitude/unified';
import { getEngagement } from './engagement';

export interface User {
  id: string;
  plan?: string;
}

@Injectable({ providedIn: 'root' })
export class AuthService {
  readonly user = signal<User | null>(null);

  signIn(user: User): void {
    // ... your sign-in logic
    this.user.set(user);
  }

  async signOut(): Promise<void> {
    // ... your sign-out logic
    this.user.set(null);
    getEngagement()?.gs.closeAll();
    amplitude.reset();
  }

// -- src/app/feedback-button.ts --
import { Component } from '@angular/core';
import { getEngagement } from './engagement';

@Component({
  selector: 'app-feedback-button',
  template: `<button type="button" (click)="openFeedback()">Give feedback</button>`,
})
export class FeedbackButton {
  protected openFeedback(): void {
    getEngagement()?.gs.show('product-feedback');
  }

// -- src/app/app.ts (add to your root component) --
// Angular 19 and earlier: src/app/app.component.ts, class AppComponent.
// Keep lines other Angular pages added here: nav links (/build/angular-session-replay,
// /build/angular-heatmaps), ServerFlagsService (/build/angular-feature-flags).
import { EngagementBridgeService } from './engagement-bridge.service';
import { FeedbackButton } from './feedback-button';

@Component({
  // Add FeedbackButton to your existing imports array.
  imports: [RouterOutlet, RouterLink, FeedbackButton],
  template: `
    <nav>
      <!-- Keep your existing links and sign-in buttons; add: -->
      <app-feedback-button />
    </nav>
    <router-outlet />
  `,
})
export class App {
  constructor() {
    inject(AmplitudeIdentityService);
    // Registers guide links and Run callback buttons once, in the browser.
    inject(EngagementBridgeService);
  }

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

@Component({
  // Add RouterLink to the component's imports.
  imports: [RouterLink],
  template: `
    <!-- Add to your existing template. -->
    <a routerLink="/projects/new" data-guide="new-project-button">New project</a>
  `,
})
export class Home {}

// Unchanged from earlier pages; keep your versions: src/environments/environment.ts, src/environments/environment.development.ts, src/main.ts, src/app/amplitude-identity.service.ts, src/app/projects/new-project.ts, src/app/app.routes.ts.
```

## 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
First complete https://amplitude.com/build/angular-product-analytics, or
confirm this Angular app already has its setup: @amplitude/unified,
amplitudeApiKey and production in both environment files, initAmplitude()
in src/app/amplitude.ts called from src/main.ts before
bootstrapApplication, AuthService exposing the user as a signal,
AmplitudeIdentityService injected in the root component's constructor,
and the Home and NewProject routes. Use the root component file the app
already has (app.ts or app.component.ts); never create a second one.

Then add Amplitude guides and surveys by following
https://amplitude.com/build/angular-guides-surveys exactly. Other Amplitude
Build pages for Angular add lines to the same files this page touches.
Merge into existing files and never delete lines another Amplitude Build
page added. Don't change the environment files, new-project.ts,
app.routes.ts or AmplitudeIdentityService.

Keep the single initAll call in src/app/amplitude.ts and everything
already there: sessionReplay.debugMode and privacyConfig, pageViews:
{ trackHistoryChanges: 'pathOnly' }, the experiment block and any
analytics.deviceId handoff, and the webExperiment.plugin() registration
after initAll. If src/app/amplitude-ready.ts exists, keep it and its
setAmplitudeReady(...) wrapper; otherwise create it from the page and
pass the initAll promise directly to setAmplitudeReady(), with no
Promise.resolve wrapper (setAmplitudeReady(null) when the key is
missing), without returning it from initAmplitude(). Never call
engagement.init() or engagement.boot(), and never initialize in a
component, service, provideAppInitializer, or main.server.ts. Read
window.engagement only through getEngagement() in src/app/engagement.ts,
which returns undefined on the server; don't augment the global Window
type.

Add src/app/engagement-bridge.service.ts as on the page: return early
when not in the browser, wait for amplitudeReady and return if it
resolves false, warn if window.engagement is missing, call setRouter with
a function that turns the URL into an in-app path (same origin, base href
stripped, hash route for HashLocationStrategy, falling back to the path
when the URL has no hash) and navigates with router.navigateByUrl inside
NgZone.run, and register addCallback('open_new_project', ...) that
navigates to /projects/new the same way. Inject it in the root
component's constructor after AmplitudeIdentityService. Add
src/app/feedback-button.ts that calls
getEngagement()?.gs.show('product-feedback') on click, and add
<app-feedback-button /> to the existing root template, keeping its nav
links and any other inject(...) lines. In src/app/home.ts, add
data-guide="new-project-button" to an existing link to /projects/new (the
Web Experiment page's Create a project link, for example), or add a New
project link with that attribute if there's none; keep any data-exp
attributes. In the sign-out method, call getEngagement()?.gs.closeAll()
before amplitude.reset(), keep any existing lines there (including the
feature flags page's FlagsService clear() and refetch() after reset()),
and call reset() only after a successful sign-out, never when the user
is null.

If a CSP blocks inline styles, tell me rather than inventing a nonce
workaround. If the app sends Cross-Origin-Opener-Policy, set it to
same-origin-allow-popups, or unsafe-none if the app also runs Web
Experiment. Tell me session replay records every session under ng serve
and about 1% in production, and how to turn it off. Report every file
you changed.
```

## What this unlocks

- [Session replay](https://amplitude.com/session-replay), on at the sample rate from step 2, lets you watch what users did right after they submitted `product-feedback`.
- [Heatmaps](https://amplitude.com/heatmaps) are built from session replays, so they show whether people click `new-project-button` more after the pin goes live.
- [Feature Experiment](https://amplitude.com/amplitude-experiment), bundled in the same package, can gate the feature a guide announces, so the flag and the guide reach the same users.
- [Product analytics](https://amplitude.com/amplitude-analytics) charts `Guide Viewed` next to `Project Created`, so you can see whether the pin moves project creation.

One integration; everything else reads from the same events.

## Next steps

[Web frameworksHow to add feature flags and experiments to an Angular appGate the feature a guide announces behind a flag read from a signal-based service.](/build/angular-feature-flags)[Web frameworksHow to add session replay to an Angular appRecord the sessions around a guide or survey, with the masking rules this page keeps.](/build/angular-session-replay)[Web frameworksHow to add heatmaps to an Angular appSee where people click before and after a pin goes live.](/build/angular-heatmaps)

Reference docs

[Guides and Surveys Web SDK →](https://amplitude.com/docs/sdks/guides-and-surveys/sdk)[Browser Unified SDK →](https://amplitude.com/docs/sdks/analytics/browser/browser-unified-sdk)[Setup and Targeting →](https://amplitude.com/docs/guides-and-surveys/setup-and-target)[Testing and Publishing →](https://amplitude.com/docs/guides-and-surveys/testing)[Guide or Survey not showing →](https://amplitude.com/docs/guides-and-surveys/troubleshooting/guide-or-survey-not-showing)[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-guides-surveys.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)
