How to add heatmaps to an Angular app

Turn on session replay in an Angular app from `main.ts`, switch on heatmap capture for the project, give each settings tab its own child route so heatmaps don't merge them, and create a click map for a dynamic route. About 15 minutes of setup, then heatmaps fill in as traffic arrives.

  • Angular 20
  • Unified SDK 1.x
  • 15 min

Chris Tam · Last updated 14 October 2026

Angular Router maps each URL in your app to a component. Heatmaps show where people click and how far they scroll on each of those pages, so you see which parts of a screen get used and which get skipped.

I turn on session replay for heatmaps, give each settings tab its own route, and create a heatmap ready to fill as traffic arrives. The complete setup on this page works without completing another tutorial.

What you'll build

An Angular app that records session replay on every route, sends [Amplitude] Page Viewed with the same device_id as the replay, keeps the masking rules from the session replay page, and gives the Profile and Billing tabs of /settings their own child routes, plus a click map in Amplitude for every /projects/:projectId page. It works with and without Angular SSR. By the end you'll see a replay of your test session and a heatmap set up to fill in once traffic crosses the data threshold.

Prerequisites

An Angular 20 app with standalone components. Angular SSR (@angular/ssr) is optional.

A Node.js 22.12+ (22.x) or Node.js 24 environment and pnpm for the Angular 20 demo below. An existing app can use the same additions after merging them into its current configuration.

  • Path-based routing, the provideRouter default. The heatmap URL filters in step 5 assume routes in the path, such as /settings/billing, not after a # from withHashLocation().
  • An Amplitude project API key. In Amplitude, go to Settings > Projects > your project > General.
  • Access to heatmaps in Amplitude. Any user with View permissions can create a heatmap; turning on heatmap capture for a project takes an Admin or Manager role.

Heatmaps are available on the Plus, Growth, and Enterprise plans, for customers with session replay; compare plans. New to Amplitude? Sign up for free to get your project API key.

Amplitude recommends a separate development project for testing heatmaps, so your test clicks stay out of production data. Heatmap and session replay events don't count toward your event volume.

1. Install the SDK

I use the Browser Unified SDK installed with pnpm. Its initAll call accepts analytics and session replay options. The complete setup below includes the SDK dependency; if your app already initializes Amplitude, merge only the additions.

I start with the complete demo below. No earlier Build tutorial is required. In an existing app, merge the SDK and route additions into its current files; keep authentication, identity handling and other instrumentation. The setup block contains separate files: create each named file before running the terminal commands.

Complete setup files (split at each filename)
// -- package.json --
{
  "name": "angular-heatmaps",
  "private": true,
  "packageManager": "pnpm@10.33.2+sha512.a90faf6feeab71ad6c6e57f94e0fe1a12f5dcc22cd754db40ae9593eb6a3e0b6b12e3540218bb37ae083404b1f2ce6db2a4121e979829b4aff94b99f49da1cf8",
  "scripts": {
    "start": "ng serve",
    "build": "ng build"
  },
  "dependencies": {
    "@amplitude/unified": "1.1.42",
    "@angular/common": "20.3.34",
    "@angular/compiler": "20.3.34",
    "@angular/core": "20.3.34",
    "@angular/platform-browser": "20.3.34",
    "@angular/router": "20.3.34",
    "rxjs": "7.8.2",
    "tslib": "2.8.1",
    "zone.js": "0.15.1"
  },
  "devDependencies": {
    "@angular/build": "20.3.40",
    "@angular/cli": "20.3.40",
    "@angular/compiler-cli": "20.3.34",
    "typescript": "5.9.3"
  }
}

// -- pnpm-workspace.yaml --
minimumReleaseAge: 10080
trustPolicy: no-downgrade

// -- angular.json --
{
  "$schema": "./node_modules/@angular/cli/lib/config/schema.json",
  "version": 1,
  "projects": {
    "demo": {
      "projectType": "application",
      "root": "",
      "sourceRoot": "src",
      "architect": {
        "build": {
          "builder": "@angular/build:application",
          "options": {
            "browser": "src/main.ts",
            "tsConfig": "tsconfig.app.json",
            "index": "src/index.html",
            "polyfills": [
              "zone.js"
            ],
            "assets": [
              {
                "glob": "**/*",
                "input": "public"
              }
            ],
            "styles": []
          },
          "configurations": {
            "production": {
              "outputHashing": "all"
            },
            "development": {
              "optimization": false,
              "sourceMap": true,
              "fileReplacements": [
                {
                  "replace": "src/environments/environment.ts",
                  "with": "src/environments/environment.development.ts"
                }
              ]
            }
          },
          "defaultConfiguration": "production"
        },
        "serve": {
          "builder": "@angular/build:dev-server",
          "configurations": {
            "production": {
              "buildTarget": "demo:build:production"
            },
            "development": {
              "buildTarget": "demo:build:development"
            }
          },
          "defaultConfiguration": "development"
        }
      }
    }
  }
}

// -- tsconfig.json --
{
  "compileOnSave": false,
  "compilerOptions": {
    "strict": true,
    "skipLibCheck": true,
    "experimentalDecorators": true,
    "importHelpers": true,
    "target": "ES2022",
    "module": "preserve",
    "moduleResolution": "bundler",
    "lib": [
      "ES2022",
      "dom"
    ],
    "useDefineForClassFields": false
  },
  "angularCompilerOptions": {
    "strictInjectionParameters": true,
    "strictInputAccessModifiers": true,
    "strictTemplates": true
  }
}

// -- tsconfig.app.json --
{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "outDir": "./out-tsc/app",
    "types": []
  },
  "files": [
    "src/main.ts"
  ],
  "include": [
    "src/**/*.d.ts"
  ]
}

// -- src/index.html --
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>Local demo</title><base href="/"><meta name="viewport" content="width=device-width, initial-scale=1"></head><body><app-root></app-root></body></html>

// -- src/environments/environment.ts --
export const environment = { production: true, amplitudeApiKey: 'your-project-api-key' };

// -- src/environments/environment.development.ts --
export const environment = { production: false, amplitudeApiKey: 'your-project-api-key' };

// -- src/main.ts --
import { bootstrapApplication } from '@angular/platform-browser';
import { App } from './app/app';
import { appConfig } from './app/app.config';
import { initAmplitude } from './app/amplitude';

// Run before Angular creates its zone. Do not add this to main.server.ts.
initAmplitude();
bootstrapApplication(App, appConfig).catch(console.error);

// -- src/app/app.config.ts --
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = { providers: [provideZoneChangeDetection(), provideRouter(routes)] };

// -- src/app/app.ts --
import { Component } from '@angular/core';
import { RouterLink, RouterOutlet } from '@angular/router';
@Component({
  selector: 'app-root',
  imports: [RouterLink, RouterOutlet],
  template: `<nav><a routerLink="/">Home</a> <a routerLink="/settings/profile">Profile</a> <a routerLink="/settings/billing">Billing</a> <a routerLink="/projects/demo-1">Sample project</a></nav><router-outlet />`
})
export class App {}

// -- src/app/home.ts --
import { Component } from '@angular/core';
@Component({ selector: 'app-home', template: '<h1>Local demo</h1><p>Use the navigation to exercise the tutorial.</p>' })
export class Home {}

// -- src/app/projects/new-project.ts --
import { Component } from '@angular/core';
@Component({ selector: 'app-new-project', template: '<h1>New project</h1>' })
export class NewProject {}
Terminal
mkdir angular-heatmaps-demo
cd angular-heatmaps-demo
# Create the named setup files shown on this page, then install:
pnpm install
# Start the app after adding the SDK and route files in the remaining steps.

Already on @amplitude/analytics-browser to keep bundle size down? Add the Session Replay Browser SDK Plugin to your existing init instead of installing a second SDK. It takes the same options as the sessionReplay block in step 2.

Heatmaps need Session Replay Browser SDK Plugin 1.7.0 or later. The Verify section shows how to read the version your page actually runs.

2. Initialize with session replay on

Session replay's default sample rate is 0, which records nothing. Setting sessionReplay.sampleRate above 0 turns recording on, and recording is what heatmaps are built from.

Other Angular pages add lines to src/app/amplitude.ts, and the comment at the top of the file below names each one. Keep those lines in your file and change only the sessionReplay block, which is all heatmaps need. A blind paste removes them with no error in the console: flags never fetch, the guides router never registers, and Web Experiment's On Event Tracked triggers stop firing. The block keeps the masking rules from the session replay page, since heatmap backgrounds are replay snapshots and unmasked text would show up in them.

src/app/amplitude.ts
// Keep lines other Angular pages added here: pageViews: { trackHistoryChanges: 'pathOnly' }
// (/build/angular-web-analytics); debugMode and privacyConfig (/build/angular-session-replay,
// already below); 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);
// the WebExperimentGlobal type and the amplitude.add(webExperiment.plugin()) block
// (/build/angular-web-experiment).
import * as amplitude from '@amplitude/unified';
import { environment } from '../environments/environment';

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

  // This page doesn't wait for initAll. If setAmplitudeReady(...) wraps this call, keep it.
  void amplitude.initAll(environment.amplitudeApiKey, {
    // This tutorial does not load guides and surveys. Keep your existing engagement setup if you use it.
    engagement: { skip: true },
    analytics: {
      autocapture: {
        attribution: true,
        pageViews: true,
        sessions: true,
        formInteractions: true,
        fileDownloads: true,
        elementInteractions: true,
      },
    },
    sessionReplay: {
      useWebWorker: false, // Explicitly use main-thread processing for this demo.
      // Every session while you build; about 1% in production.
      sampleRate: environment.production ? 0.01 : 1,
      // Adds [Amplitude] Session Replay Debug with an API key hash. Initial setup only.
      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]'],
      },
    },
  });
}

The root layout, entry point and routes needed for this demo are included in the complete setup and full code reference. In an existing app, keep its identity and sign-out code.

Why. Initializing more than once can leave replay and analytics events with different device or session IDs. Heatmaps still draw an unsegmented map in that case, but segment and cohort filters come back empty, because Amplitude can't join the clicks to your users. main.ts runs once per page load, in the browser only; with Angular SSR the server renders from main.server.ts and never starts the recorder. Never add a second initAll in a component, a service, provideAppInitializer, or app.config.ts. Those run inside the Angular zone, which the base page explains breaks autocapture, and app.config.ts is shared with the server build.

Heatmap interactions come from session replay. This demo also enables elementInteractions for click events in product analytics. Leave pageUrlEnrichment at its default enabled setting so your custom events receive the current page URL.

Be deliberate about the sample rate. Every heatmap reflects only the sessions you captured, so at 0.01 a map is built from 1% of traffic and takes longer to cross the data threshold. Amplitude recommends starting low and raising the rate over a few days; capture stops for the month once your org's replay quota runs out. Session Replay Settings in Amplitude can also set the project's rate remotely, so check there when the rate you see doesn't match your code. Heatmap backgrounds show only what your masking lets through; read the Angular session replay page before you raise the rate in production.

Guides and surveys initialize by default in the Unified SDK. This demo sets engagement: { skip: true } because it does not use them. Keep your current engagement configuration if your existing app does use them, and follow that SDK's CSP requirements.

3. Turn on heatmap capture in Amplitude

The SDK records replays, but the project decides whether heatmap data gets collected. In Amplitude, go to Settings > Organizational Settings > Session Replay Settings. The project list shows a session capture toggle and a heatmap capture toggle for each project. Turn both on for this project.

Select the project to open its Sampling control. A rate set there applies to the project without a deploy, which is the easier place to raise capture once heatmaps need more data.

4. Give each tab its own route

Heatmaps compute data per URL string, not per view. A tab bar that swaps content with a signal and @switch, or a tab group component, never changes the URL, so the Profile and Billing tabs of /settings end up in one heatmap, drawn over whichever tab the background happened to catch. Angular Router fixes this with child routes: each tab becomes a child of settings, and the tab bar lives in a layout component with its own <router-outlet />.

src/app/settings/settings-layout.ts
import { Component } from '@angular/core';
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';

@Component({
  selector: 'app-settings-layout',
  imports: [RouterLink, RouterLinkActive, RouterOutlet],
  template: `
    <section>
      <h1>Settings</h1>
      <nav>
        <a routerLink="profile" routerLinkActive="active">Profile</a>
        <a routerLink="billing" routerLinkActive="active">Billing</a>
      </nav>
      <!-- The active tab renders here. -->
      <router-outlet />
    </section>
  `,
})
export class SettingsLayout {}
src/app/settings/profile-tab.ts
import { Component } from '@angular/core';

@Component({
  selector: 'app-profile-tab',
  template: `<h2>Profile</h2>`,
})
export class ProfileTab {}
src/app/settings/billing-tab.ts
import { Component } from '@angular/core';

@Component({
  selector: 'app-billing-tab',
  template: `<h2>Billing</h2>`,
})
export class BillingTab {}

The heatmap in step 5 targets a dynamic project route. If your app doesn't have a project screen yet, add a minimal one:

src/app/projects/project-detail.ts
import { Component, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { ActivatedRoute } from '@angular/router';
import { map } from 'rxjs';

@Component({
  selector: 'app-project-detail',
  template: `<h1>Project {{ projectId() }}</h1>`,
})
export class ProjectDetail {
  // The router reuses this component when you move between projects,
  // so read the ID as a signal instead of from the snapshot.
  protected readonly projectId = toSignal(
    inject(ActivatedRoute).paramMap.pipe(map((params) => params.get('projectId'))),
  );
}

The complete route file below includes the demo home route and the new screens. For an existing app, merge these routes into its current array and keep all existing routes and titles.

src/app/app.routes.ts
// Keep lines other Angular pages added here: their routes, such as team and billing
// (/build/angular-session-replay) and pricing, welcome, login, account, plans and offer/:code
// (/build/angular-web-analytics), and the title on each route if you added titles there.
import { Routes } from '@angular/router';
import { Home } from './home';
import { NewProject } from './projects/new-project';
import { ProjectDetail } from './projects/project-detail';
import { SettingsLayout } from './settings/settings-layout';
import { ProfileTab } from './settings/profile-tab';
import { BillingTab } from './settings/billing-tab';

export const routes: Routes = [
  { path: '', component: Home },
  // Keep this above projects/:projectId, or "new" is read as a project ID.
  { path: 'projects/new', component: NewProject },
  { path: 'projects/:projectId', component: ProjectDetail },
  {
    path: 'settings',
    component: SettingsLayout,
    children: [
      { path: '', redirectTo: 'profile', pathMatch: 'full' },
      { path: 'profile', component: ProfileTab },
      { path: 'billing', component: BillingTab },
    ],
  },
];

With Angular SSR, ng new --ssr prerenders every route, and a route with a parameter can't be prerendered without a list of IDs, so pnpm build fails on projects/:projectId with a missing getPrerenderParams error. Add the project route above the '**' entry in src/app/app.routes.server.ts, and keep any entries other Angular pages added there. Angular 19 apps without that file (CommonEngine) skip parameterized routes during prerendering and need no change.

src/app/app.routes.server.ts (Angular SSR only)
// Keep lines other Angular pages added here: plans, offer/:code and account
// (/build/angular-web-analytics) and projects/new (/build/angular-feature-flags), all above '**'.
import { RenderMode, ServerRoute } from '@angular/ssr';

export const serverRoutes: ServerRoute[] = [
  // Project IDs aren't known at build time, so render these per request.
  { path: 'projects/:projectId', renderMode: RenderMode.Server },
  { path: '**', renderMode: RenderMode.Prerender },
];

The complete src/app/app.ts in the setup block includes the demo navigation. For an existing app, add the links below to its current navigation without replacing the component.

src/app/app.ts (Angular 19 and earlier: app.component.ts): add inside the template's <nav>
<!-- Keep lines other Angular pages added here: the Team, Billing and Invoices links
     (/build/angular-session-replay); EngagementBridgeService, the FeedbackButton import and
     <app-feedback-button /> (/build/angular-guides-surveys); ServerFlagsService
     (/build/angular-feature-flags). -->
<a routerLink="/projects/demo-1">Sample project</a>
<a routerLink="/settings">Settings</a>

Now /settings/profile and /settings/billing get separate heatmaps, and each tab switch also sends its own [Amplitude] Page Viewed. The router applies redirectTo before it updates the URL, so the Settings link lands on /settings/profile without stopping on /settings. On a direct load or refresh of /settings, the redirect replaces the URL rather than adding a history entry, and autocapture doesn't send a page view for a replaced URL. That visit's one page view can record /settings instead of /settings/profile, so link to /settings/profile directly in emails and docs. With Angular SSR, the default '**' Prerender entry resolves the /settings redirect at build time, so it always points to /settings/profile; linking /settings/profile directly still saves the extra hop. Heatmap clicks come from the replay, not from page views, so the heatmaps themselves aren't affected. None of these components call the SDK, so they render the same with Angular SSR. The settings layout renders inside the root router-outlet shown in step 1. Keep existing identity handling when merging into your app.

Why. Put the tab in the path, not in a query string such as /settings?tab=billing set with [queryParams] and read from ActivatedRoute. Heatmaps need the [Amplitude] Page URL property to match URLs, and that property is the page URL with query parameters removed, so /settings?tab=profile and /settings?tab=billing can land in the same heatmap. A path segment is unambiguous. Dialogs and drawers that don't change the URL also merge into the page underneath; if one matters, give it a child route the same way.

routerLink and redirectTo resolve against the <base href> in src/index.html, so the routes work if you deploy under a sub-path. Your heatmap URL filters have to include that sub-path, and the URL scheme should stay put once heatmaps are collecting: heatmap data is keyed by the URL string, so changing <base href> or switching to hash routing mid-stream splits one page's data across two URLs.

5. Create your first heatmap

Heatmaps appear after Amplitude collects enough replay click data across your site: at least 10 separate pages, with about 500 tracked replay clicks on each page. These thresholds are approximate. Create the heatmap now and it fills in once traffic crosses them.

  1. In Amplitude, click Heatmaps in the left navigation, then + New Heatmap.
  2. Choose Click map. You can switch to a selector map or scroll map at any time.
  3. Select the URL. projects/:projectId gives every project its own URL, so use Pattern match with https://your-domain.com/projects/* to combine them into one map. That pattern also matches /projects/new, so give the create screen its own Exact match heatmap if you want to see it separately. Use Exact match for a single page, such as https://your-domain.com/settings/billing, and Starts with for a section, such as https://your-domain.com/settings.
  4. Optionally pick a segment of users by user property or cohort.
  5. Pick a device type. Heatmaps group data into four screen-width buckets: greater than 1440px, 900 to 1440px, 500 to 899px, and less than 500px. A responsive layout that collapses its nav into a menu below a CSS breakpoint looks different in each bucket, and the background and the click data must fall in the same one.
  6. Choose a background. Each heatmap generates eight, taken from the page states with the most actions. Skip any that caught an open dialog or a loading state.

Once your data meets the thresholds, Amplitude also creates a few heatmaps automatically for your highest-traffic pages. You can delete them; they aren't recreated.

Keep personal data out of URL paths. If your application needs URL redaction, follow the linked URL filtering guide and verify the integration in a development project before production capture. Do not assume masking page text also redacts its URL.

MCP. Working in Cursor or Claude Code? Amplitude's MCP server lets your agent read your project's existing event taxonomy before it adds track calls near a heatmap hotspot, so new events match names you already use instead of inventing planUpgraded next to Plan Upgraded. Set up the MCP server.

Verify it worked

Local verification checks the app's routes and replay instrumentation. Run pnpm start, open http://localhost:4200 in a fresh window, and use the demo navigation. Live replay playback and heatmap results require a configured Amplitude project and the documented processing/data requirements.

  1. Network tab. Filter for amplitude. You should see event uploads to api2.amplitude.com returning 200, a request to sr-client-cfg.amplitude.com, and replay uploads to api-sr.amplitude.com. With Angular SSR, none of them come from the server render; they start once the page loads in the browser.
  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.

User lookup. Go to Users > User Profiles and search the device ID shown by the Instrumentation Explorer extension. Trigger an interaction after initialization and inspect its event properties for [Amplitude] Session Replay ID. Events appear in ten seconds to one minute; replays are generally available about five minutes after a session begins. A local-only test does not establish ingestion or playback.

  1. Session Replay debug panel. In the Instrumentation Explorer Chrome extension, turn on the Session Replay debug panel. It shows the current replay ID, the session replay SDK and plugin versions, the sample rate, and the default and effective masking settings. The plugin version must be 1.7.0 or later, and the sample rate must not be 0.
  2. Heatmaps. Open the heatmap from step 5. In a development project it shows "No heatmap available" until the thresholds are met. After you deploy, confirm the live site serves the updated SDK, not only your local build.

Troubleshooting

The heatmap is blank or says "No heatmap available"

Work through the causes in order: Amplitude is still processing new replay data (a few minutes after install), the URL filter doesn't match URLs in your events, the deployed site runs a session replay plugin older than 1.7.0, or traffic hasn't reached about 500 replay clicks on each of 10 pages. If [Amplitude] Page URL was hidden, restore it in Data > Properties.

Every tab on a screen shows up in one heatmap

The tabs share a URL. Move each one to a child route as in step 4. A tab kept in a query parameter can merge too, because [Amplitude] Page URL drops query parameters; move it into the path.

pnpm build fails with "'projects/:projectId' route uses prerendering and includes parameters"

The default '**' Prerender entry in src/app/app.routes.server.ts covers the project route. Add { path: 'projects/:projectId', renderMode: RenderMode.Server } above '**', as in step 4.

The routes are out of order. Angular Router uses the first route that matches, so projects/new has to come before projects/:projectId in src/app/app.routes.ts.

No replays at all, even with a sample rate of 1

For a strict CSP, retain the policy your app needs and allow the complete endpoint set for its region. US connect-src: https://api2.amplitude.com https://api-sr.amplitude.com https://sr-client-cfg.amplitude.com. EU connect-src: https://api.eu.amplitude.com https://api-sr.eu.amplitude.com https://sr-client-cfg.eu.amplitude.com, with serverZone set to EU at the top level of initAll options. This demo explicitly sets useWebWorker to false. If your existing app enables it, allow worker-src blob: too. The demo skips guides and surveys; if your app enables them, also apply their documented CSP requirements. A blocked remote-configuration request prevents replay capture.

A background shows no data

The background's viewport width falls in a different screen-width bucket from your recorded sessions. Use Change Screenshot > From a Replay and pick a screenshot taken at a width where your data exists.

Segment or cohort filters return nothing

Replay and analytics events carry different device_id values, so Amplitude can't join clicks to users. Look for a second initAll or init call, for example in a component, a service, or provideAppInitializer, or a standalone session replay SDK running beside your analytics. Keep the single initAmplitude() call in src/main.ts.

"Heatmaps isn't available for your organization"

Your organization predates Amplitude's current packaging and needs manual enablement. Contact Amplitude Support.

Full code reference

Every file needed for the standalone demo is included below. Split the block at its filename comments. For an existing app, merge changes into its files and keep any existing authentication, identity, routes and other Amplitude configuration.

20 complete files
// -- src/app/amplitude.ts --
// Keep lines other Angular pages added here: pageViews: { trackHistoryChanges: 'pathOnly' }
// (/build/angular-web-analytics); debugMode and privacyConfig (/build/angular-session-replay,
// already below); 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);
// the WebExperimentGlobal type and the amplitude.add(webExperiment.plugin()) block
// (/build/angular-web-experiment).
import * as amplitude from '@amplitude/unified';
import { environment } from '../environments/environment';

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

  // This page doesn't wait for initAll. If setAmplitudeReady(...) wraps this call, keep it.
  void amplitude.initAll(environment.amplitudeApiKey, {
    // This tutorial does not load guides and surveys. Keep your existing engagement setup if you use it.
    engagement: { skip: true },
    analytics: {
      autocapture: {
        attribution: true,
        pageViews: true,
        sessions: true,
        formInteractions: true,
        fileDownloads: true,
        elementInteractions: true,
      },
    },
    sessionReplay: {
      useWebWorker: false, // Explicitly use main-thread processing for this demo.
      // Every session while you build; about 1% in production.
      sampleRate: environment.production ? 0.01 : 1,
      // Adds [Amplitude] Session Replay Debug with an API key hash. Initial setup only.
      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/settings/settings-layout.ts --
import { Component } from '@angular/core';
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';

@Component({
  selector: 'app-settings-layout',
  imports: [RouterLink, RouterLinkActive, RouterOutlet],
  template: `
    <section>
      <h1>Settings</h1>
      <nav>
        <a routerLink="profile" routerLinkActive="active">Profile</a>
        <a routerLink="billing" routerLinkActive="active">Billing</a>
      </nav>
      <!-- The active tab renders here. -->
      <router-outlet />
    </section>
  `,
})
export class SettingsLayout {}

// -- src/app/settings/profile-tab.ts --
import { Component } from '@angular/core';

@Component({
  selector: 'app-profile-tab',
  template: `<h2>Profile</h2>`,
})
export class ProfileTab {}

// -- src/app/settings/billing-tab.ts --
import { Component } from '@angular/core';

@Component({
  selector: 'app-billing-tab',
  template: `<h2>Billing</h2>`,
})
export class BillingTab {}

// -- src/app/projects/project-detail.ts --
import { Component, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { ActivatedRoute } from '@angular/router';
import { map } from 'rxjs';

@Component({
  selector: 'app-project-detail',
  template: `<h1>Project {{ projectId() }}</h1>`,
})
export class ProjectDetail {
  // The router reuses this component when you move between projects,
  // so read the ID as a signal instead of from the snapshot.
  protected readonly projectId = toSignal(
    inject(ActivatedRoute).paramMap.pipe(map((params) => params.get('projectId'))),
  );
}

// -- src/app/app.routes.ts --
// Keep lines other Angular pages added here: their routes, such as team and billing
// (/build/angular-session-replay) and pricing, welcome, login, account, plans and offer/:code
// (/build/angular-web-analytics), and the title on each route if you added titles there.
import { Routes } from '@angular/router';
import { Home } from './home';
import { NewProject } from './projects/new-project';
import { ProjectDetail } from './projects/project-detail';
import { SettingsLayout } from './settings/settings-layout';
import { ProfileTab } from './settings/profile-tab';
import { BillingTab } from './settings/billing-tab';

export const routes: Routes = [
  { path: '', component: Home },
  // Keep this above projects/:projectId, or "new" is read as a project ID.
  { path: 'projects/new', component: NewProject },
  { path: 'projects/:projectId', component: ProjectDetail },
  {
    path: 'settings',
    component: SettingsLayout,
    children: [
      { path: '', redirectTo: 'profile', pathMatch: 'full' },
      { path: 'profile', component: ProfileTab },
      { path: 'billing', component: BillingTab },
    ],
  },
];

// -- package.json --
{
  "name": "angular-heatmaps",
  "private": true,
  "packageManager": "pnpm@10.33.2+sha512.a90faf6feeab71ad6c6e57f94e0fe1a12f5dcc22cd754db40ae9593eb6a3e0b6b12e3540218bb37ae083404b1f2ce6db2a4121e979829b4aff94b99f49da1cf8",
  "scripts": {
    "start": "ng serve",
    "build": "ng build"
  },
  "dependencies": {
    "@amplitude/unified": "1.1.42",
    "@angular/common": "20.3.34",
    "@angular/compiler": "20.3.34",
    "@angular/core": "20.3.34",
    "@angular/platform-browser": "20.3.34",
    "@angular/router": "20.3.34",
    "rxjs": "7.8.2",
    "tslib": "2.8.1",
    "zone.js": "0.15.1"
  },
  "devDependencies": {
    "@angular/build": "20.3.40",
    "@angular/cli": "20.3.40",
    "@angular/compiler-cli": "20.3.34",
    "typescript": "5.9.3"
  }
}

// -- pnpm-workspace.yaml --
minimumReleaseAge: 10080
trustPolicy: no-downgrade

// -- angular.json --
{
  "$schema": "./node_modules/@angular/cli/lib/config/schema.json",
  "version": 1,
  "projects": {
    "demo": {
      "projectType": "application",
      "root": "",
      "sourceRoot": "src",
      "architect": {
        "build": {
          "builder": "@angular/build:application",
          "options": {
            "browser": "src/main.ts",
            "tsConfig": "tsconfig.app.json",
            "index": "src/index.html",
            "polyfills": [
              "zone.js"
            ],
            "assets": [
              {
                "glob": "**/*",
                "input": "public"
              }
            ],
            "styles": []
          },
          "configurations": {
            "production": {
              "outputHashing": "all"
            },
            "development": {
              "optimization": false,
              "sourceMap": true,
              "fileReplacements": [
                {
                  "replace": "src/environments/environment.ts",
                  "with": "src/environments/environment.development.ts"
                }
              ]
            }
          },
          "defaultConfiguration": "production"
        },
        "serve": {
          "builder": "@angular/build:dev-server",
          "configurations": {
            "production": {
              "buildTarget": "demo:build:production"
            },
            "development": {
              "buildTarget": "demo:build:development"
            }
          },
          "defaultConfiguration": "development"
        }
      }
    }
  }
}

// -- tsconfig.json --
{
  "compileOnSave": false,
  "compilerOptions": {
    "strict": true,
    "skipLibCheck": true,
    "experimentalDecorators": true,
    "importHelpers": true,
    "target": "ES2022",
    "module": "preserve",
    "moduleResolution": "bundler",
    "lib": [
      "ES2022",
      "dom"
    ],
    "useDefineForClassFields": false
  },
  "angularCompilerOptions": {
    "strictInjectionParameters": true,
    "strictInputAccessModifiers": true,
    "strictTemplates": true
  }
}

// -- tsconfig.app.json --
{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "outDir": "./out-tsc/app",
    "types": []
  },
  "files": [
    "src/main.ts"
  ],
  "include": [
    "src/**/*.d.ts"
  ]
}

// -- src/index.html --
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>Local demo</title><base href="/"><meta name="viewport" content="width=device-width, initial-scale=1"></head><body><app-root></app-root></body></html>

// -- src/environments/environment.ts --
export const environment = { production: true, amplitudeApiKey: 'your-project-api-key' };

// -- src/environments/environment.development.ts --
export const environment = { production: false, amplitudeApiKey: 'your-project-api-key' };

// -- src/main.ts --
import { bootstrapApplication } from '@angular/platform-browser';
import { App } from './app/app';
import { appConfig } from './app/app.config';
import { initAmplitude } from './app/amplitude';

// Run before Angular creates its zone. Do not add this to main.server.ts.
initAmplitude();
bootstrapApplication(App, appConfig).catch(console.error);

// -- src/app/app.config.ts --
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = { providers: [provideZoneChangeDetection(), provideRouter(routes)] };

// -- src/app/app.ts --
import { Component } from '@angular/core';
import { RouterLink, RouterOutlet } from '@angular/router';
@Component({
  selector: 'app-root',
  imports: [RouterLink, RouterOutlet],
  template: `<nav><a routerLink="/">Home</a> <a routerLink="/settings/profile">Profile</a> <a routerLink="/settings/billing">Billing</a> <a routerLink="/projects/demo-1">Sample project</a></nav><router-outlet />`
})
export class App {}

// -- src/app/home.ts --
import { Component } from '@angular/core';
@Component({ selector: 'app-home', template: '<h1>Local demo</h1><p>Use the navigation to exercise the tutorial.</p>' })
export class Home {}

// -- src/app/projects/new-project.ts --
import { Component } from '@angular/core';
@Component({ selector: 'app-new-project', template: '<h1>New project</h1>' })
export class NewProject {}

// -- public/avatar.svg --
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32"><rect width="32" height="32" fill="#0052F2"/><circle cx="16" cy="12" r="6" fill="white"/><path d="M5 31c0-14 22-14 22 0" fill="white"/></svg>

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
Follow https://amplitude.com/build/angular-heatmaps using only the complete setup and Full code reference on this page. Do not require another tutorial. Split the reference at filename comments and create every required file. For an existing app, merge additions and keep authentication, identity, routes and other instrumentation. Use the existing root component/layout file. Initialize Amplitude once in src/main.ts before bootstrapApplication via initAmplitude(). Keep the empty-key guard and the existing ready-promise wrapper if present. Use development sample rate 1 and production 0.01, with debugMode enabled only in development. This option adds diagnostic information; current recording has no focus gate. Keep the masking selectors and do not place personal data in HTML attributes. Use the page's demo routes to exercise the behavior. Keep engagement.skip true for this demo, or retain the existing engagement configuration and its required CSP if the app uses guides and surveys. Check the full endpoint set for the selected region. Build the actual app and run its Verify steps; report build, local browser behavior and live service results separately. Do not claim ingestion or playback from a mocked test.

Frequently asked questions

Do I need to finish the product analytics tutorial first?

No. This page includes the setup and complete demo files. In an existing app, merge the additions into the SDK initialization you already have.

Does debugMode bypass a focus check?

Current SDK recording has no focus gate. debugMode adds a diagnostic event property containing an API key hash. Use it for initial setup and keep it off in production.

What can I verify without an Amplitude project?

You can check builds, navigation, DOM attributes and local browser behavior. Successful ingestion, replay playback and processed heatmaps need a configured project and separate verification.

What this unlocks

  • Session replay, already on from step 2, lets you select an area of a click map and watch the sessions behind those clicks.
  • Product analytics can chart [Amplitude] Page Viewed for /settings/billing against your upgrade event to see whether the tab you just mapped converts.
  • Feature Experiment, bundled in @amplitude/unified, can test a new billing layout, and a heatmap per variant shows how the clicks moved.
  • Guides and surveys, also bundled, can point users to a button the click map shows they miss.

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.