Learn Google Analytics GA4 in a Prerendered Angular App

Oct 26, 2026AngularEngineeringSEO
Summarize with AI: Google AI Claude ChatGPT Perplexity Grok

AI links open with a title + excerpt (these tools can't fetch the page themselves) — use "Copy full article" to paste the complete text for a fuller summary.

Share:

Introduction

This article demonstrates how to add Google Analytics 4 to an Angular app so that page views are actually recorded when a visitor moves between routes.

Paste the standard GA4 snippet into an Angular app and it appears to work. Real time shows visitors. Then a week later your reports say every visitor looked at exactly one page and left.

The Problem With SPAs

GA4's snippet sends a page_view when the page loads. In a normal website every link is a new page load, so that is every page.

In a single page app the browser loads once. After that, Angular's router swaps components and updates the URL with the History API. No page load happens, so no page_view is sent.

So the first page a visitor lands on is tracked. The next ten are invisible. Every session looks like a bounce.

Features of the fix

  • One small service, no library.
  • Tracks the real URL after redirects.
  • Sends the correct page title.
  • Does nothing during prerendering.

With the following steps, you can fix it.

  1. Add the GA4 snippet.
  2. Turn off automatic page views.
  3. Send a page view on each navigation.
  4. Add the CSP hosts.

Add the GA4 Snippet

The standard snippet goes in index.html.

<script async src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX"></script>
<script>
  window.dataLayer = window.dataLayer || [];
  function gtag(){dataLayer.push(arguments);}
  gtag('js', new Date());
  gtag('config', 'G-XXXXXXXXXX');
</script>

Get the measurement ID from the GA4 admin — Admin → Data streams → your web stream.

The measurement ID in a GA4 data stream

Turn Off Automatic Page Views

This is the step that is missing from most articles, and skipping it gives you double counting on your landing page.

gtag('config', 'G-XXXXXXXXXX', { send_page_view: false });

Otherwise GA4 sends one page_view on load, and your router code sends another for the same route a moment later. Now your landing page has twice the traffic of any other, which quietly ruins every comparison in your reports.

Turn the automatic one off and let your code own it. One source, no guessing which events came from where.

Send a Page View on Each Navigation

@Injectable({ providedIn: 'root' })
export class AnalyticsService {
  private readonly platformId = inject(PLATFORM_ID);
  private readonly router = inject(Router);

  constructor() {
    if (!isPlatformBrowser(this.platformId)) return;
    this.router.events
      .pipe(filter((e): e is NavigationEnd => e instanceof NavigationEnd))
      .subscribe((e) => {
        if (typeof gtag === 'undefined') return;
        gtag('event', 'page_view', {
          page_path: e.urlAfterRedirects,
          page_title: document.title,
        });
      });
  }
}

Inject it once in app.component.ts and it runs for the life of the app.

Five decisions in twenty lines.

isPlatformBrowser guard, first line. Our app is prerendered at build time, where there is no window, no document and no analytics. Without this guard the build crashes. With it, the service is simply inert on the server.

NavigationEnd, not NavigationStart. Track navigations that completed. A cancelled navigation — a guard rejecting it, a redirect firing — never reaches NavigationEnd, so it is never counted. Tracking on start would record pages nobody saw.

urlAfterRedirects, not url. If /old-post redirects to /new-post, url gives you the address the router started with. urlAfterRedirects gives you the page the visitor actually saw. Use the wrong one and your top pages list is full of URLs that do not exist any more.

typeof gtag === 'undefined' check. The GA4 script is async and loads from an external domain. An ad blocker, a network failure or a slow connection means gtag may not exist when a navigation happens. Without this guard you throw a ReferenceError inside a router subscription — which can tear down the subscription and stop all later tracking, so one blocked script silently ends analytics for that session.

document.title for the title. Sent at the moment of navigation. If you set titles with Angular's Title service, make sure that happens before or during the navigation, not after — otherwise you record the previous page's title against the new URL.

The declare function gtag(...args: unknown[]): void; at the top of the file tells TypeScript about the global that the HTML snippet defines.

Add the CSP Hosts

If you have a Content Security Policy, GA4 will be blocked until you widen it. It needs entries in two different directives.

script-src  'self' … https://www.googletagmanager.com;
connect-src 'self' … https://www.google-analytics.com
                     https://analytics.google.com
                     https://region1.google-analytics.com;

script-src for the tag manager script. Then three hosts in connect-src for where it sends data.

That third one, region1.google-analytics.com, is the one everybody misses. GA4 sends to a regional endpoint depending on where the visitor is, and it may not be region1 for you. So your own testing looks fine and some of your visitors are silently blocked.

My suggestion: after adding analytics, open the site in a browser, watch the console for CSP violations, and check the GA4 Realtime report from a different network or a phone on mobile data. A CSP that works from your desk is not proof.

GA4 Realtime showing page views across routes

Verify It Properly

Navigate through several routes and watch Realtime → Event count by Event name. You should see one page_view per route change.

Two specific things to check.

Your landing page should not be double counted. If it is, send_page_view: false is missing.

Deep-linked pages should report correctly. Load /blog/some-post directly in a fresh tab — the prerendered HTML is served, then the app hydrates, then the router fires NavigationEnd. That is the path most of your search traffic takes.

One Thing This Article Does Not Cover

Consent. Depending on where your visitors are, you may need permission before loading analytics at all. That means not running the snippet until consent is given, and using GA4's consent mode.

We have not implemented it. I would rather say so than leave you thinking a page_view fix is the whole job.

Conclusion

In this article we learned why a single page app only reports one page view per session, how to fix it by disabling send_page_view and sending one on each NavigationEnd, why urlAfterRedirects and the gtag existence check both matter, and which three hosts GA4 needs in your connect-src.

The service is twenty lines. The details that cost you real data are the automatic page view you forgot to turn off, and the regional analytics host your CSP does not allow.

Reference

#google-analytics#ga4#angular#spa#csp#router

Comments

Be the first to comment.

Leave a comment

Never shown publicly.

Comments are reviewed before appearing publicly.