---
title: "How to add web analytics to an Angular app"
description: "Track page views, sessions, and UTM attribution in an Angular app, keep campaign data through router redirects and guards, and read traffic by channel."
canonical: "https://amplitude.com/en-us/build/angular-web-analytics"
---

# How to add web analytics to an Angular app

Track page views, sessions, and UTM attribution in an Angular app, keep campaign data through router redirects and guards, and read traffic by channel.

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

---

# How to add web analytics to an Angular app

Configure the Amplitude Unified SDK so every Angular Router navigation becomes a page view, every visit carries its UTM and referrer data, and sessions feed bounce rate and session length. Then keep campaign parameters alive through \`redirectTo\`, guards, \`router.navigate()\`, and Angular SSR server redirects, and open the Marketing Analytics views that read them. About 15 minutes, all copy-paste.

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

Chris Tam · Last updated 14 October 2026

Angular loads once, then the router handles every navigation in the browser, so most of a visit happens without a page load. [Web analytics](https://amplitude.com/web-analytics) tells you where those visitors come from, which campaigns bring them, and which pages keep them.

In this tutorial, you'll configure Amplitude for traffic data, keep campaign parameters through Angular redirects and guards, and set up the channel and campaign views. It builds on the [Angular product analytics page](/build/angular-product-analytics).

## What you'll build

An Angular app that sends `[Amplitude] Page Viewed` on first load and on every path change, starts and ends sessions automatically, and stores `utm_source`, `utm_medium`, `utm_campaign`, `referrer`, and click IDs as both user properties and event properties, including on visits that land on a URL a `redirectTo`, a guard, a `router.navigate()` call, or an Angular SSR server redirect rewrites. It works with and without Angular SSR. By the end you'll see a test campaign in your event stream and a Marketing Analytics view configured to read it.

## Prerequisites

- The setup from the [Angular product analytics page](/build/angular-product-analytics): Angular 19 or later with standalone components, `@amplitude/unified`, the two environment files, `src/app/amplitude.ts`, the `initAmplitude()` call in `src/main.ts`, and the stand-in `AuthService` with its `user` signal. Step 4 uses `AuthService` for a sign-in guard. 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`.
- An Amplitude project API key. In Amplitude, go to Settings > Projects > your project > General.
- An Admin or Manager role in the Amplitude project for step 5. The Member role can't edit Marketing Analytics settings.

A free account covers the tracking on this page. [Sign up for free](https://app.amplitude.com/signup) to get your project API key.

## 1. Install the SDK

[The Quickstart](https://amplitude.com/docs/get-started/amplitude-quickstart) recommends `@amplitude/unified` for browser apps installed from npm, and the [Angular product analytics page](/build/angular-product-analytics) uses it, so this page changes the same `src/app/amplitude.ts`. If you already followed that page, skip to step 2.

Terminal

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

The project API key lives in `src/environments/environment.ts` and `environment.development.ts`, which the base page creates and this page doesn't change. The key is public and its scope is limited to the minimum needed to ingest data, so shipping it in the browser bundle is expected. `ng serve` reads `environment.development.ts`; `ng build` reads `environment.ts`.

## 2. Configure autocapture for traffic data

Attribution, page views, sessions, and page URL enrichment are all on by default. Name them anyway, so nobody turns one off later without knowing what it feeds, and change one: `trackHistoryChanges`. This is the complete file, with the base page's clicks and session replay settings kept.

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 and change only the `autocapture` block. A blind paste removes them with no error: flags never fetch, the guides router never registers, and Web Experiment's On Event Tracked triggers stop firing.

src/app/amplitude.ts

```typescript
// Keep lines other Angular pages added here: 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, {
    analytics: {
      autocapture: {
        // UTM, referrer, and click ID capture. Feeds Traffic by Channel and Campaign.
        attribution: true,
        // [Amplitude] Page Viewed on load and on every path change.
        // 'pathOnly' ignores query-string-only changes such as ?sort=price.
        // Using withHashLocation()? Read step 3 before keeping this line.
        pageViews: { trackHistoryChanges: 'pathOnly' },
        // [Amplitude] Start Session and End Session. Feeds bounce rate and session length.
        sessions: true,
        // Adds current and previous page URL properties.
        pageUrlEnrichment: 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.** `main.ts` runs once in the browser, before Angular bootstraps and before the router runs its first navigation, so `initAll` starts before any route code. The SDK finishes initializing asynchronously, though: it fetches remote configuration first (on by default) and reads campaign data from the URL after that. That has a consequence for step 4. A redirect the router makes in the browser can happen before or after the SDK reads the URL, so don't count on the original URL being seen. A redirect the server makes, through Angular SSR or a rule in `server.ts`, happens before any JavaScript runs, so the SDK only ever sees the destination. Keep `initAmplitude()` out of components, services, `app.config.ts`, and `main.server.ts`; the Browser SDK docs ask Angular apps with Zone.js to initialize outside the Angular zone, and `main.ts` before bootstrap is outside it.

`@amplitude/unified` turns on session replay when `sessionReplay.sampleRate` is above `0`. This config records every session under `ng serve` and about 1% in a production build, which is where Amplitude recommends starting; raise it over a few days. Set it to `0` to turn session replay off. Web analytics doesn't need it; it's here because the base page set it. `debugMode` and the `privacyConfig` selectors come from the [Angular session replay page](/build/angular-session-replay): `debugMode` ignores the focus check so replays record with DevTools open in development, and the selectors match nothing until you add the `data-amp-*` attributes, so the block is safe to keep either way.

From Browser SDK 2.45.0, the SDK stores campaign parameters two ways by default: as user properties (including first-touch `initial_utm_source` and friends, set once) and as event properties on every event. If the Network tab check below shows no `utm_` values in `event_properties`, update `@amplitude/unified` to a release that bundles Browser SDK 2.45.0 or later. For referrer exclusions and the other attribution options, see [Track marketing attribution](https://amplitude.com/docs/sdks/analytics/browser/browser-sdk-2#track-marketing-attribution).

## 3. Let autocapture count navigations

There's no page view code in this app, and there shouldn't be. Angular Router changes the URL with the browser's History API, and with `pageViews` on, the SDK sends `[Amplitude] Page Viewed` on init and again when the URL changes, which covers `routerLink` clicks, `router.navigate()`, and `router.navigateByUrl()`. Each event carries `[Amplitude] Page Path`, `[Amplitude] Page Title`, `[Amplitude] Page URL`, `[Amplitude] Page Location`, and `[Amplitude] Page Counter`. Navigations that replace the URL instead of pushing a new history entry, such as `replaceUrl: true` or a `redirectTo` applied during the app's first navigation, don't send a page view.

`trackHistoryChanges: 'pathOnly'` sends a page view when the path changes, such as `/pricing` to `/docs`, and skips changes to only the query string or hash. Use the default, `'all'`, if your query strings mark different pages, such as `/blog?page=2`. With `'all'`, a product list that writes its filters to the URL with `router.navigate([], { queryParams: { sort: 'price' } })` records a page view per click, which inflates page views per session and skews entry and exit pages.

Two Angular specifics follow from the same rule. Matrix parameters (`/products;sort=price`) are part of the path, so `'pathOnly'` counts every change to them; keep filter state in query parameters if you don't want page views for it. A navigation with `skipLocationChange: true` never touches the URL, so it records nothing.

Give every route a `title` (step 4 does). Angular sets `document.title` from it, and that's what `[Amplitude] Page Title` reads; without one, every page in Page Engagement shares your app's default title.

Angular routes on the path by default. With `provideRouter(routes, withHashLocation())`, the route moves into the hash (`/#/pricing`). `[Amplitude] Page Path` excludes the hash, so it reads `/` on every screen, and with `'pathOnly'` route changes never send a page view. With hash routing, set `pageViews: true`, which tracks hash changes, and break page reports down by `[Amplitude] Page Location` (the full URL) rather than `[Amplitude] Page Path`. Campaign links then need their UTM parameters in the real query string, before the `#`: `/?utm_source=newsletter#/pricing`. A query after the `#` belongs to Angular's router, not the browser, and the SDK doesn't read it. Hash routing also has no server redirects, because the server only ever sees `/`, so step 4 doesn't apply.

Don't subscribe to `Router` events to call `track('Page Viewed')`. It produces a second, differently named page view per navigation, and Marketing Analytics only counts the one it's configured to read.

## 4. Keep UTM parameters through Angular redirects

The SDK can only record campaign data that's in the URL. Angular redirects go exactly where you point them, and a bare path has no query string. A visitor clicks a newsletter link to `/account?utm_source=newsletter`, a guard sends them to `/login`, and the address bar ends up on a bare `/login`. Amplitude's docs call this out for single-page apps: UTM parameters can drop during SPA redirects.

How much you lose depends on where the redirect runs:

- In the browser, through a `redirectTo`, a guard, a resolver, or `router.navigate()`. The SDK finishes initializing asynchronously, so depending on timing it can read the URL before or after the redirect. Don't count on first-touch values surviving: carry the parameters across, and every event after the redirect keeps the campaign too.
- On the server, through Angular SSR rendering the same routes and guards, or a redirect rule in `server.ts`. The browser loads the destination directly, so the SDK starts on `/login`, never sees the original URL, and records no campaign at all.

Four patterns drop the parameters:

- A route with a string `redirectTo` that starts with `/`, such as an old campaign URL you moved. Angular builds the new URL from the string alone.
- A functional guard or resolver that returns `router.createUrlTree(['/login'])`, `router.parseUrl('/login')`, or a `RedirectCommand` built from either.
- A `router.navigate(['/welcome'])` or `router.navigateByUrl('/welcome')` after sign-in.
- With Angular SSR, an Express route in `src/server.ts` that calls `res.redirect()` with a bare path.

Carry the campaign parameters across with one small helper file. It copies only the UTM and click ID parameters the SDK reads, so it never drags unrelated query keys onto the new page. `campaignParams` works on Angular's query params; `withCampaign` works on URL strings for `server.ts`:

src/app/campaign.ts

```typescript
import type { Params } from '@angular/router';

// The UTM and click ID parameters the Browser SDK reads for attribution.
const CAMPAIGN_KEYS = [
  'utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'utm_id',
  'gclid', 'fbclid', 'dclid', 'gbraid', 'wbraid', 'ko_click_id',
  'li_fat_id', 'msclkid', 'rdt_cid', 'ttclid', 'twclid',
];

// For router code: createUrlTree(['/login']) on its own drops ?utm_source=...
export function campaignParams(from: Params): Params {
  const picked: Params = {};
  for (const key of CAMPAIGN_KEYS) {
    if (from[key] !== undefined) {
      picked[key] = from[key];
    }
  return picked;
}

// For server.ts: res.redirect(308, '/pricing') on its own drops ?utm_source=...
export function withCampaign(path: string, fromUrl: string): string {
  const base = 'http://localhost'; // Only for parsing; never part of the result.
  const from = new URL(fromUrl, base);
  const target = new URL(path, base);
  for (const key of CAMPAIGN_KEYS) {
    const value = from.searchParams.get(key);
    if (value !== null && !target.searchParams.has(key)) {
      target.searchParams.set(key, value);
    }
  return target.pathname + target.search;
}
```

Use it in every guard, reading the query params of the route being checked:

src/app/auth.guard.ts

```typescript
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { AuthService } from './auth.service';
import { campaignParams } from './campaign';

export const authGuard: CanActivateFn = (route) => {
  if (inject(AuthService).user()) {
    return true;
  }
  return inject(Router).createUrlTree(['/login'], {
    queryParams: campaignParams(route.queryParams),
  });
};
```

Read the params from the `route` argument, not with `queryParamsHandling: 'preserve'`. In a guard, `'preserve'` copies the query of the URL the router is currently on, not the one it's checking, and on a first visit that URL has no query at all. If your guard already passes a `returnUrl`, keep it and spread `campaignParams(route.queryParams)` next to it: the SDK reads top-level query keys, not ones encoded inside `returnUrl`.

Replace string redirects with redirect functions, which run in an injection context and receive the incoming query params. This is the complete routes file, with the base page's two routes kept and a `title` on every route. Merge these routes into your existing array; the comment at the top names the routes other Angular pages add, so keep those and give them a `title` too:

src/app/app.routes.ts

```typescript
// Keep lines other Angular pages added here: their routes, such as team and billing
// (/build/angular-session-replay) and projects/:projectId and settings (/build/angular-heatmaps).
import { inject } from '@angular/core';
import { Router, Routes } from '@angular/router';
import { authGuard } from './auth.guard';
import { campaignParams } from './campaign';
import { Home } from './home';
import { Login } from './login';
import { Account, Pricing, Welcome } from './pages';
import { NewProject } from './projects/new-project';

export const routes: Routes = [
  { path: '', component: Home, title: 'Home' },
  { path: 'projects/new', component: NewProject, title: 'New project' },
  { path: 'pricing', component: Pricing, title: 'Pricing' },
  { path: 'welcome', component: Welcome, title: 'Welcome' },
  { path: 'login', component: Login, title: 'Sign in' },
  { path: 'account', component: Account, title: 'Account', canActivate: [authGuard] },
  {
    // Old campaign URL, moved to /pricing for good.
    path: 'plans',
    pathMatch: 'full',
    redirectTo: ({ queryParams }) =>
      inject(Router).createUrlTree(['/pricing'], {
        queryParams: campaignParams(queryParams),
      }),
  },
  {
    // Sets its own query, so campaignParams adds ?utm_... next to it.
    path: 'offer/:code',
    pathMatch: 'full',
    redirectTo: ({ params, queryParams }) =>
      inject(Router).createUrlTree(['/pricing'], {
        queryParams: { offer: params['code'], ...campaignParams(queryParams) },
      }),
  },
];
```

And after sign-in. `auth.signIn` is the base page's stand-in; keep your own sign-in call and change only the `navigate` line:

src/app/login.ts

```typescript
import { Component, inject } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { AuthService } from './auth.service';
import { campaignParams } from './campaign';

@Component({
  selector: 'app-login',
  template: `
    <h1>Sign in</h1>
    <button type="button" (click)="signIn()">Sign in and continue</button>
  `,
})
export class Login {
  private readonly auth = inject(AuthService);
  private readonly router = inject(Router);
  private readonly route = inject(ActivatedRoute);

  protected async signIn(): Promise<void> {
    // ... your sign-in logic
    this.auth.signIn({ id: '42', plan: 'pro' });

    // This page is /login?utm_source=..., because the guard kept the campaign.
    await this.router.navigate(['/welcome'], {
      queryParams: campaignParams(this.route.snapshot.queryParams),
    });
  }
```

In a component, `queryParamsHandling: 'preserve'` would also keep the campaign, because the current URL is the right one there, but it carries every key on the page, including ones such as `returnUrl` that don't belong on `/welcome`.

If you use Angular SSR and redirect old URLs in `src/server.ts` before Angular renders, wrap those too. Add the import at the top of the file and the route after the `express.static` block, before the handler that calls `angularApp.handle`. If your `server.ts` is at the project root (apps created before Angular 19), import from `./src/app/campaign`:

src/server.ts (add to your existing file)

```typescript
// Keep lines other Angular pages added here: the flag evaluation middleware
// (/build/angular-feature-flags) and the header middleware (/build/angular-web-experiment).
import { withCampaign } from './app/campaign';

// Old landing page, moved before Angular renders anything.
app.get('/old-landing', (req, res) => {
  res.redirect(308, withCampaign('/pricing', req.originalUrl));
});
```

With Angular SSR, render redirecting and guarded routes on the server per request, not at build time, so the redirect sees the incoming query. `ng new --ssr` prerenders every route by default, and a prerendered `/plans` or `/account` is built once with no query string. Add the three routes above the `'**'` entry in `src/app/app.routes.server.ts`, and keep any entries other Angular pages added there:

src/app/app.routes.server.ts (Angular SSR only)

```typescript
// Keep lines other Angular pages added here: projects/new (/build/angular-feature-flags) and
// projects/:projectId (/build/angular-heatmaps), all above '**'.
import { RenderMode, ServerRoute } from '@angular/ssr';

export const serverRoutes: ServerRoute[] = [
  // Redirects and guards that must see each request's UTM parameters.
  { path: 'plans', renderMode: RenderMode.Server },
  { path: 'offer/:code', renderMode: RenderMode.Server },
  { path: 'account', renderMode: RenderMode.Server },
  { path: '**', renderMode: RenderMode.Prerender },
];
```

If your project has no `app.routes.server.ts` (Angular 19 apps created without server routing use `CommonEngine`), build-time prerendering is controlled by the `prerender` option in `angular.json`. Leave `/plans` and `/account` out of the prerendered routes, or turn prerendering off.

**Why.** Event property tracking re-reads campaign parameters on page load and on every SPA navigation, including the `replaceState` an Angular redirect performs. Whatever is in the URL after the redirect is what gets attached to the events that follow. With Angular SSR and those routes rendered per request, the same routes and guards also run on the server for the first request, so the helper in your guards and redirect functions covers the server redirect and the browser redirect at once. For the same reason, don't strip UTM parameters from the address bar with `router.navigate([], { queryParams: {} })` to tidy the URL.

Check each guard, redirect, and sign-in navigation in your app with a `?utm_source=test` URL before you trust your channel numbers.

The Verify steps below visit `/pricing`, `/welcome`, `/account`, and `/login`. If your app doesn't have the first three yet, add a stub for each:

src/app/pages.ts

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

@Component({ selector: 'app-pricing', template: `<h1>Pricing</h1>` })
export class Pricing {}

@Component({ selector: 'app-welcome', template: `<h1>Welcome</h1>` })
export class Welcome {}

@Component({ selector: 'app-account', template: `<h1>Account</h1>` })
export class Account {}
```

## 5. Set up the Marketing Analytics views

In Amplitude, web analytics lives under **Marketing Analytics** in the navigation bar. It uses Browser SDK events by default, so this step is configuration, not code.

1. Create the channel classifier. Go to Data > Properties > Channels. Opening that page creates `[Amplitude] Default Channels`, which maps UTM parameters and referrer data into GA4-aligned channels such as Paid Search and Organic Social. If your org predates it, click **Build with the new logic** when prompted.
2. Confirm the page view event. Open Marketing Analytics, click **Customize**, and select Page View and Filter. Choose `[Amplitude] Page Viewed` as the event. Set a domain filter if the project also receives events from another site.
3. Define a conversion funnel. Under Conversion Funnel, choose the events that make up your funnel. If you followed the [Angular product analytics page](/build/angular-product-analytics), `[Amplitude] Page Viewed` followed by `Project Created` works as a first funnel.
4. Read the views. Traffic by Channel, Traffic by Campaign, Page Engagement, and Conversion each show a time series and a table. Page Engagement covers visitors, page views, bounce rate, page views per session, and entry and exit rate per page. Ad Performance needs an ad network source such as Google Ads connected first.

Saving these settings as separate views for different teams needs a Growth or Enterprise plan; [compare plans](https://amplitude.com/pricing). For every setting, see [Out-of-the-box Marketing Analytics](https://amplitude.com/docs/analytics/ootb-marketing-analytics).

**MCP.** Working in Cursor or Claude Code? Amplitude's MCP server lets your agent look up the events already in your project before it writes `track` calls, so a conversion event matches a name you already use instead of inventing `projectCreated` next to `Project Created`. [Set up the MCP server](https://amplitude.com/docs/amplitude-ai/amplitude-mcp).

## Verify it worked

Run `ng serve`, open `http://localhost:4200/offer/spring?utm_source=build-test&utm_medium=email&utm_campaign=angular-web-analytics` in a fresh private window, click through two pages, then check in this order:

1. **Redirects.** Turn on Preserve log in the Network tab first. The address bar should read `/pricing?offer=spring&utm_source=build-test&utm_medium=email&utm_campaign=angular-web-analytics`. With Angular SSR, the server can answer the document request with a `3xx` instead; if it does, its `Location` header must carry the same parameters. Then open `/account?utm_source=build-test` in the same window while signed out: you should land on `/login?utm_source=build-test`. Click **Sign in and continue** under the Sign in heading, and you should land on `/welcome?utm_source=build-test`. If any of these loses its parameters, the fix is step 4, and nothing downstream will work until it is.
2. **Network tab.** Filter for `api2.amplitude.com`. You should see a `POST` to `/2/httpapi` returning `200`. The SDK compresses request bodies of 2 KB or more, so read the events in the Instrumentation Explorer Chrome extension instead: the first `[Amplitude] Page Viewed` should carry `utm_source: build-test` in `event_properties`. Its `[Amplitude] Page Path` is `/pricing` on most first visits; it can read `/offer/spring` if the SDK initialized before the redirect, and the redirect itself doesn't send a second page view. An identify event should set `utm_source` and `initial_utm_source` as user properties. With Angular SSR, Amplitude requests start only after the page loads in the browser; the server render sends none.
3. **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.
4. **User lookup.** Go to Users > User Profiles and search `user-42`. Within ten seconds to a minute, the event stream shows `[Amplitude] Start Session`, then one `[Amplitude] Page Viewed` per page, and the profile shows `initial_utm_source: build-test`. Events from before you signed in belong to the same device ID.
5. **Marketing Analytics.** Open Traffic by Campaign for today. `angular-web-analytics` should appear as a campaign. It can take a few minutes to appear.

If you added the `server.ts` redirect, run `npm run build`, then start the server with the `serve:ssr:` script that `ng new --ssr` added to `package.json`, and request it with `curl -I "http://localhost:4000/old-landing?utm_source=build-test"`: the response should be a `308` whose `Location` header is `/pricing?utm_source=build-test`.

To watch payloads in the console, add `logLevel: amplitude.Types.LogLevel.Debug` to the `analytics` block in development.

## Troubleshooting

### No requests in the Network tab

The key is empty, so the warning from step 2 is in the console. `ng serve` reads `environment.development.ts` and `ng build` reads `environment.ts`, so check the file your configuration uses. Because the key is compiled into the bundle, changing it in production means rebuilding.

### Campaign traffic shows up as Direct

The UTM parameters were gone before the SDK read the URL. Open a `?utm_source=test` URL with Preserve log on and watch the address bar and the `Location` header of every `3xx` document response: a string `redirectTo`, a guard that returns `createUrlTree` or `parseUrl` with a bare path, or a `res.redirect()` in `server.ts` is the usual cause. The Channels docs also note that inconsistent `set()` and `unset()` calls on UTM user properties can classify traffic as Other.

### Some events have a campaign and later ones don't

A redirect in the browser dropped the parameters after the SDK read them. Look for a `router.navigate()` or `navigateByUrl()` after sign-in, or a guard that uses `queryParamsHandling: 'preserve'`, and pass `campaignParams(...)` as in step 4.

### Two page view events per navigation

A `Router` events subscription or a page view helper is running alongside autocapture. Delete it. If both events are `[Amplitude] Page Viewed`, check that `initAmplitude()` is only called from `src/main.ts`.

### A page view every time a filter or sort changes

The page writes state to the URL with `router.navigate([], { queryParams: ... })` and `trackHistoryChanges` is `'all'`. Set `pageViews: { trackHistoryChanges: 'pathOnly' }` as in step 2. If the filter state lives in matrix parameters (`;sort=price`), move it to query parameters.

### Only one page view per visit, no matter how many pages

The app uses `withHashLocation()` with `trackHistoryChanges: 'pathOnly'`. Every route shares the path `/`, so no path change ever happens. Set `pageViews: true`, or switch back to path routing.

### Every page in Page Engagement has the same title

Your routes have no `title`, so every `[Amplitude] Page Title` is the app's default from `index.html`. Add `title` to each route, as in step 4, or break the view down by `[Amplitude] Page Path` under Customize > Breakdown.

### Page views stopped even though the code enables them

Remote configuration overrides local autocapture settings feature by feature, and Browser SDK 2.16.1 and later fetch it by default. Check Data > Settings > Autocapture and make sure page views and sessions aren't turned off there. To keep autocapture under code control, add `remoteConfig: { fetchRemoteConfig: false }` to the `analytics` block.

### Requests fail with ERR\_BLOCKED\_BY\_CLIENT

An ad blocker is dropping the call. Route events through a [proxy on your own domain](https://amplitude.com/docs/sdks/analytics/proxy-requests) and set `serverUrl` in the `analytics` block to point at it. Point `remoteConfig.serverUrl` at it too, so remote configuration requests aren't blocked.

## Full code reference

Every file from this tutorial in one block, the version to hand to an agent. 6 files, plus `src/app/app.routes.server.ts` and one addition to `src/server.ts` if you use Angular SSR. Each shared file (`amplitude.ts`, `app.routes.ts`, `app.routes.server.ts`, `server.ts`) starts with a comment naming the lines other Angular pages add there; merge into your file and keep those lines. 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`, `src/app/home.ts`, `src/app/projects/new-project.ts`.

8 files, 196 lines

```typescript
// -- src/app/amplitude.ts --
// Keep lines other Angular pages added here: 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, {
    analytics: {
      autocapture: {
        // UTM, referrer, and click ID capture. Feeds Traffic by Channel and Campaign.
        attribution: true,
        // [Amplitude] Page Viewed on load and on every path change.
        // 'pathOnly' ignores query-string-only changes such as ?sort=price.
        // Using withHashLocation()? Read step 3 before keeping this line.
        pageViews: { trackHistoryChanges: 'pathOnly' },
        // [Amplitude] Start Session and End Session. Feeds bounce rate and session length.
        sessions: true,
        // Adds current and previous page URL properties.
        pageUrlEnrichment: 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/campaign.ts --
import type { Params } from '@angular/router';

const CAMPAIGN_KEYS = [
  'utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'utm_id',
  'gclid', 'fbclid', 'dclid', 'gbraid', 'wbraid', 'ko_click_id',
  'li_fat_id', 'msclkid', 'rdt_cid', 'ttclid', 'twclid',
];

export function campaignParams(from: Params): Params {
  const picked: Params = {};
  for (const key of CAMPAIGN_KEYS) {
    if (from[key] !== undefined) {
      picked[key] = from[key];
    }
  return picked;
}

export function withCampaign(path: string, fromUrl: string): string {
  const base = 'http://localhost'; // Only for parsing; never part of the result.
  const from = new URL(fromUrl, base);
  const target = new URL(path, base);
  for (const key of CAMPAIGN_KEYS) {
    const value = from.searchParams.get(key);
    if (value !== null && !target.searchParams.has(key)) {
      target.searchParams.set(key, value);
    }
  return target.pathname + target.search;
}

// -- src/app/auth.guard.ts --
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { AuthService } from './auth.service';
import { campaignParams } from './campaign';

export const authGuard: CanActivateFn = (route) => {
  if (inject(AuthService).user()) {
    return true;
  }
  return inject(Router).createUrlTree(['/login'], {
    queryParams: campaignParams(route.queryParams),
  });
};

// -- src/app/login.ts --
import { Component, inject } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { AuthService } from './auth.service';
import { campaignParams } from './campaign';

@Component({
  selector: 'app-login',
  template: `
    <h1>Sign in</h1>
    <button type="button" (click)="signIn()">Sign in and continue</button>
  `,
})
export class Login {
  private readonly auth = inject(AuthService);
  private readonly router = inject(Router);
  private readonly route = inject(ActivatedRoute);

  protected async signIn(): Promise<void> {
    // ... your sign-in logic
    this.auth.signIn({ id: '42', plan: 'pro' });

    await this.router.navigate(['/welcome'], {
      queryParams: campaignParams(this.route.snapshot.queryParams),
    });
  }

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

@Component({ selector: 'app-pricing', template: `<h1>Pricing</h1>` })
export class Pricing {}

@Component({ selector: 'app-welcome', template: `<h1>Welcome</h1>` })
export class Welcome {}

@Component({ selector: 'app-account', template: `<h1>Account</h1>` })
export class Account {}

// -- src/app/app.routes.ts --
// Keep lines other Angular pages added here: their routes, such as team and billing
// (/build/angular-session-replay) and projects/:projectId and settings (/build/angular-heatmaps).
import { inject } from '@angular/core';
import { Router, Routes } from '@angular/router';
import { authGuard } from './auth.guard';
import { campaignParams } from './campaign';
import { Home } from './home';
import { Login } from './login';
import { Account, Pricing, Welcome } from './pages';
import { NewProject } from './projects/new-project';

export const routes: Routes = [
  { path: '', component: Home, title: 'Home' },
  { path: 'projects/new', component: NewProject, title: 'New project' },
  { path: 'pricing', component: Pricing, title: 'Pricing' },
  { path: 'welcome', component: Welcome, title: 'Welcome' },
  { path: 'login', component: Login, title: 'Sign in' },
  { path: 'account', component: Account, title: 'Account', canActivate: [authGuard] },
  {
    path: 'plans',
    pathMatch: 'full',
    redirectTo: ({ queryParams }) =>
      inject(Router).createUrlTree(['/pricing'], {
        queryParams: campaignParams(queryParams),
      }),
  },
  {
    path: 'offer/:code',
    pathMatch: 'full',
    redirectTo: ({ params, queryParams }) =>
      inject(Router).createUrlTree(['/pricing'], {
        queryParams: { offer: params['code'], ...campaignParams(queryParams) },
      }),
  },
];

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

export const serverRoutes: ServerRoute[] = [
  // Redirects and guards that must see each request's UTM parameters.
  { path: 'plans', renderMode: RenderMode.Server },
  { path: 'offer/:code', renderMode: RenderMode.Server },
  { path: 'account', renderMode: RenderMode.Server },
  { path: '**', renderMode: RenderMode.Prerender },
];

// -- src/server.ts (Angular SSR only; add to your existing file) --
// Keep lines other Angular pages added here: the flag evaluation middleware
// (/build/angular-feature-flags) and the header middleware (/build/angular-web-experiment).
// At the top, with the other imports:
import { withCampaign } from './app/campaign';

// After the express.static block, before the angularApp.handle handler:
app.get('/old-landing', (req, res) => {
  res.redirect(308, withCampaign('/pricing', req.originalUrl));
});
```

## Hand it to your agent

Prefer to have Cursor, Claude Code, or Codex CLI do the wiring? This prompt points the agent at this page and pins the decisions that matter.

Using Cursor or Claude Code? Paste this prompt

```text
Complete https://amplitude.com/build/angular-product-analytics first, or
confirm this Angular app already has its setup (environment files with
production and amplitudeApiKey, src/app/amplitude.ts with initAmplitude(),
the call in src/main.ts before bootstrapApplication, AuthService with a
user signal, and AmplitudeIdentityService). Then set up Amplitude web
analytics by following https://amplitude.com/build/angular-web-analytics
exactly. Merge into shared files (src/app/amplitude.ts,
src/app/app.routes.ts, src/app/app.routes.server.ts and src/server.ts) and
never delete a line another Amplitude Build page added; each shared file
on the page starts with a comment listing those lines. Don't touch the
environment files, main.ts, AuthService, AmplitudeIdentityService, the
root component, home.ts or new-project.ts. Keep initAll in
initAmplitude(), called only from src/main.ts, never from a component,
service, app.config.ts, or main.server.ts. In amplitude.ts, keep the
setAmplitudeReady(...) wrapper, its import and the setAmplitudeReady(null)
line if they exist, the experiment block and any analytics.deviceId
handoff, and the WebExperimentGlobal type and
amplitude.add(webExperiment.plugin()) block. Inside analytics: {
autocapture } keep attribution, sessions, pageUrlEnrichment,
formInteractions, fileDownloads and elementInteractions on, and set
pageViews to { trackHistoryChanges: 'pathOnly' } unless provideRouter uses
withHashLocation(), in which case use pageViews: true. Keep
sessionReplay.sampleRate at environment.production ? 0.01 : 1, debugMode:
!environment.production and the privacyConfig maskSelector, blockSelector
and unmaskSelector for the data-amp-mask, data-amp-block and
data-amp-unmask attributes, and tell me how to turn session replay off.
Don't subscribe to Router events for page views. Add src/app/campaign.ts
with campaignParams and withCampaign and the page's 6 UTM and 11 click ID
keys. Then find every string redirectTo that starts with "/", every guard
or resolver that returns createUrlTree, parseUrl or RedirectCommand, every
router.navigate or navigateByUrl after sign-in, and, if src/server.ts
exists, every res.redirect, and make each one carry the UTM and click ID
parameters from the incoming URL: redirect functions with
inject(Router).createUrlTree and campaignParams(queryParams), guards with
campaignParams(route.queryParams), never queryParamsHandling inside a
guard, and withCampaign(path, req.originalUrl) in server.ts. If the app
uses Angular SSR, set renderMode: RenderMode.Server for the plans,
offer/:code and account routes in src/app/app.routes.server.ts, above the
'**' entry and next to any entries already there, so those redirects see
each request's query. Give every route a title. Also add the page's demo
pieces so the Verify steps run: the authGuard in src/app/auth.guard.ts on
an account route, the offer/:code redirect (to /pricing?offer=<code>) and
plans redirect (to /pricing), src/app/login.ts, and the Pricing, Welcome
and Account stubs in src/app/pages.ts, all merged into
src/app/app.routes.ts next to the existing routes. If the app has its own
auth, use its signed-in check in the guard and its sign-in call in Login
and tell me. Don't hardcode a real key and don't invent event names.
Report every file you changed and every redirect you checked.
```

## What this unlocks

- [Session replay](https://amplitude.com/session-replay), already on from step 2, lets you watch what visitors with `utm_campaign: angular-web-analytics` did after they landed on `/pricing`.
- [Heatmaps](https://amplitude.com/heatmaps), built from those session replays, show where visitors click on `/pricing`, and a segment on `initial_utm_source` splits paid from organic.
- [Web Experiment](https://amplitude.com/web-experimentation) can test a new `/pricing` layout, launched from the Page Engagement table, with your conversion funnel as the metric.
- [Guides and surveys](https://amplitude.com/guides-and-surveys) can ask visitors whose `utm_medium` is `email` what brought them back.

One integration; everything else reads from the same events.

## Next steps

[Web frameworksHow to add session replay to an Angular appWatch the sessions behind your campaign traffic, with masking and sampling tuned for production.](/build/angular-session-replay)[Web frameworksHow to add Web Experiment to an Angular appTest a change to \`/pricing\` without a deploy, measured against the same traffic data.](/build/angular-web-experiment)[Web frameworksHow to add heatmaps to an Angular appSee where campaign visitors click on each page, built from the session replays you already record.](/build/angular-heatmaps)

Reference docs

[Out-of-the-box Marketing Analytics →](https://amplitude.com/docs/analytics/ootb-marketing-analytics)[Browser SDK 2: Track marketing attribution →](https://amplitude.com/docs/sdks/analytics/browser/browser-sdk-2#track-marketing-attribution)[Browser Unified SDK →](https://amplitude.com/docs/sdks/analytics/browser/browser-unified-sdk)[Channels →](https://amplitude.com/docs/data/channels)[Debug analytics →](https://amplitude.com/docs/analytics/debug-analytics)[Amplitude MCP server →](https://amplitude.com/docs/amplitude-ai/amplitude-mcp)

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

For agents

[View raw .md](/build/angular-web-analytics.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)
