UTM Parameters Not Tracked in Angular and Vue SPAs: How to Fix GA4 Attribution

Angular and Vue SPAs fire GA4's page_view event automatically exactly once — on the initial full page load. Every subsequent navigation through Angular Router or Vue Router renders a new view without a browser navigation event, so gtag.js never registers a new page. UTM parameters from the original campaign URL are captured on that first page_view, but if a user converts on a page they reached via client-side routing, GA4 records the conversion with no UTM context — the session appears as (direct)/(none) and your LinkedIn or Google campaign gets no credit. The answer is a router event listener that manually fires page_view on every navigation. But before writing any code: run mlz check against your campaign landing URL first. If UTMs are being stripped at the network level — by a redirect, a Cloudflare Worker, or an nginx rule — fixing the router tracking won't help. mlz check rules that out in one command and confirms the problem is client-side before you spend time on router instrumentation.

Large terminal window split into two halves: left side shows Angular Router NavigationEnd subscription firing gtag page_view with urlAfterRedirects; right side shows Vue Router afterEach hook and Nuxt 3 defineNuxtPlugin pattern. Below both, an mlz check command shows all checks passing in green.

Why Angular Router and Vue Router don't fire GA4 page_view automatically

GA4's gtag.js initialises by listening to window.history.pushState and popstate browser events for automatic page tracking. The problem is that Angular Router and Vue Router's navigation does not reliably trigger GA4's built-in page-change detection in all configurations. When you navigate between routes in an Angular or Vue SPA, the router calls history.pushState internally — but the GA4 config option send_page_view: true (the default) only fires on the initial script load. Subsequent pushState calls may or may not trigger GA4 depending on script load order, framework version, and how the router initialises relative to gtag.js. The only reliable pattern is to disable GA4's automatic initial page_view with send_page_view: false and fire it manually in the router callback for every navigation — including the first.

This is the same root cause as the Next.js App Router SPA tracking issue. The implementation differs by framework: Next.js uses usePathname and useSearchParams hooks; Angular uses the Router events Observable with NavigationEnd; Vue uses router.afterEach. The principle is identical: intercept every client-side navigation event and manually tell GA4 a new page appeared.

The consequence of not doing this: campaign conversions that happen on routes beyond the landing page show no UTM attribution. A user arrives at /landing?utm_source=linkedin&utm_medium=social&utm_campaign=q3, GA4 records the session with UTMs correctly, then the user navigates to /pricing via the router. If the user converts on /pricing, GA4 records that conversion against session_source=(direct) — because no page_view was ever fired for /pricing, and the session attribution logic relies on the most recent page view.

Step zero: confirm the problem is in the router, not the network

Before implementing any router fix, confirm that your campaign URL's UTM parameters actually survive the network journey to the browser. Edge-level stripping — from a Cloudflare Worker, nginx proxy rule, or cookie consent redirect — happens before JavaScript runs. If UTMs are stripped at the network layer, a router-level fix won't recover them.

mlz check "https://your-angular-app.com/landing?utm_source=linkedin&utm_medium=social&utm_campaign=q3"

When the landing URL is clean and UTMs survive the redirect chain, mlz check returns all checks passing:

mlz check — confirming UTMs reach the browser
{
  "url": "https://your-angular-app.com/landing?utm_source=linkedin&utm_medium=social&utm_campaign=q3",
  "valid": true,
  "checks": [
    { "check": "url_format",   "status": "pass", "message": "URL format is valid." },
    { "check": "ssl",          "status": "pass", "message": "URL uses HTTPS." },
    { "check": "resolution",   "status": "pass", "message": "Destination responded with 200." },
    { "check": "redirects",    "status": "pass", "message": "No redirects detected." },
    { "check": "response_time", "status": "pass", "message": "Response time: 214ms." }
  ],
  "valid": true
}

All checks passing means the UTM parameters are intact at the network layer and reach the browser correctly. The attribution problem is in your client-side router. If you see "redirects": "fail" with "utm_present_at_final_url": false, fix the network layer first — see How to Check if a Redirect Strips UTM Parameters and UTM Parameters Stripped by Cloudflare Workers or Edge Middleware.

Angular fix: subscribe to Router events with NavigationEnd

Angular's Router exposes an Observable of navigation events. The NavigationEnd event fires after Angular Router completes each navigation — including the very first. Subscribe to it in AppComponent.ngOnInit() or in a dedicated injectable service, filter for NavigationEnd, and fire gtag('event', 'page_view') on each emission.

app.component.ts — subscribe to NavigationEnd
import { Component, OnInit } from '@angular/core'
import { Router, NavigationEnd } from '@angular/router'
import { filter } from 'rxjs/operators'

@Component({
  selector: 'app-root',
  template: '<router-outlet></router-outlet>'
})
export class AppComponent implements OnInit {
  constructor(private router: Router) {}

  ngOnInit(): void {
    this.router.events
      .pipe(filter(event => event instanceof NavigationEnd))
      .subscribe((event: NavigationEnd) => {
        (window as any).gtag('event', 'page_view', {
          page_path: event.urlAfterRedirects
        })
      })
  }
}

event.urlAfterRedirects gives the final URL after any Angular-internal route redirects, which is more accurate than event.url when you have redirect rules in your route configuration. For applications with lazy-loaded modules, the event fires after the module is resolved and the component is ready to render.

If you prefer not to couple this to AppComponent, create an injectable service and call it once from AppModule:

ga4-tracking.service.ts — injectable service pattern
import { Injectable } from '@angular/core'
import { Router, NavigationEnd } from '@angular/router'
import { filter } from 'rxjs/operators'

@Injectable({ providedIn: 'root' })
export class Ga4TrackingService {
  constructor(private router: Router) {}

  init(): void {
    this.router.events
      .pipe(filter(e => e instanceof NavigationEnd))
      .subscribe((e: NavigationEnd) => {
        (window as any).gtag('event', 'page_view', {
          page_path: e.urlAfterRedirects
        })
      })
  }
}

// In AppModule constructor:
constructor(ga4: Ga4TrackingService) {
  ga4.init()
}

The service pattern is preferable for larger applications because it is instantiated once and persists across all route changes, whereas a component-based subscription depends on the component's lifecycle. Remember to also set send_page_view: false in your gtag('config', ...) call to prevent a duplicate initial page_view event.

Angular: preserve UTM parameters across route changes

When a user lands on /landing?utm_source=linkedin and then navigates to /pricing, the event.urlAfterRedirects will be /pricing with no query string — the UTMs are not attached to the new route URL. GA4 handles session-level attribution automatically: once UTMs are recorded on the first page_view event, GA4 attributes subsequent events in the same session to the same source and medium. You do not need to pass UTM parameters on every page_view call.

However, if you have custom analytics needs — or if you're sending page_view events to a dataLayer or a custom endpoint alongside GA4 — you may want to persist the initial UTMs in sessionStorage and attach them to each navigation event:

Angular — capture and persist UTMs for custom tracking
// Capture UTMs on first page load and persist them
private captureInitialUtms(): void {
  const params = new URLSearchParams(window.location.search)
  ['utm_source', 'utm_medium', 'utm_campaign',
   'utm_term', 'utm_content'].forEach(key => {
    const val = params.get(key)
    if (val) sessionStorage.setItem(key, val)
  })
}

// Helper to read persisted UTMs
private getStoredUtms(): Record<string, string> {
  const keys = ['utm_source', 'utm_medium', 'utm_campaign',
                'utm_term', 'utm_content']
  return Object.fromEntries(
    keys
      .map(k => [k, sessionStorage.getItem(k)])
      .filter(([, v]) => v !== null)
  ) as Record<string, string>
}

Call captureInitialUtms() on service initialisation (before subscribing to router events), then merge getStoredUtms() into your page_view event payload on each navigation if needed for custom pipelines.

Vue 3 fix: router.afterEach hook

Vue Router 4 (used with Vue 3) exposes a global navigation guard called router.afterEach. It fires after every completed navigation — including the initial load. Register it once after creating the router instance, before the app mounts:

router/index.js — Vue Router 4 (Vue 3)
import { createRouter, createWebHistory } from 'vue-router'

const router = createRouter({
  history: createWebHistory(),
  routes: [
    // your route definitions
  ]
})

router.afterEach((to) => {
  window.gtag('event', 'page_view', {
    page_path: to.fullPath
  })
})

export default router

to.fullPath includes the full path plus any query parameters present on the target route — so if the initial landing page still has UTMs in the URL when the first navigation fires, they appear in fullPath. For subsequent navigations to routes without UTMs (such as /pricing), fullPath will be /pricing — which is correct; GA4's session attribution handles the source/medium persistence automatically.

Also configure the initial gtag call with send_page_view: false to avoid doubling the first event:

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

Vue 2 fix: router.afterEach with Vue Router 3

The router.afterEach pattern is identical in Vue 2 with Vue Router 3. Register it in main.js after creating the router:

main.js — Vue Router 3 (Vue 2)
import Vue from 'vue'
import VueRouter from 'vue-router'
import App from './App.vue'

Vue.use(VueRouter)

const router = new VueRouter({
  mode: 'history',
  routes: [ /* your routes */ ]
})

router.afterEach((to) => {
  window.gtag('event', 'page_view', {
    page_path: to.fullPath
  })
})

new Vue({
  router,
  render: h => h(App)
}).$mount('#app')

The API is identical between Vue Router 3 and Vue Router 4. The only difference is how the router is instantiated (new VueRouter() vs createRouter()). The afterEach callback shape and the to.fullPath property work the same way in both versions.

Nuxt 3 fix: client-only plugin

Nuxt 3 applications are server-side rendered, so you must ensure the GA4 tracking code only runs in the browser. Use a Nuxt plugin with the .client.js suffix — Nuxt automatically omits client-only plugins from the server-side render bundle:

plugins/ga4-tracking.client.js — Nuxt 3
export default defineNuxtPlugin(() => {
  const router = useRouter()

  router.afterEach((to) => {
    // window.gtag is available if you loaded GA4 via nuxt.config or a script tag
    if (typeof window !== 'undefined' && window.gtag) {
      window.gtag('event', 'page_view', {
        page_path: to.fullPath
      })
    }
  })
})

The typeof window !== 'undefined' guard is an extra safety check that prevents errors if the plugin file is ever imported in a server context. The .client.js suffix is the primary protection. If you are using @nuxtjs/gtag or a third-party Nuxt GA4 module, check whether the module already provides route tracking — some modules expose a trackRoutes option that does this automatically.

Vue: preserve UTM parameters across routes

Vue Router's navigation guards give you access to to.query — the query parameters of the destination route. On the initial navigation, to.query includes UTMs if they were in the campaign URL. On subsequent navigations (e.g. from /landing?utm_source=linkedin to /pricing), to.query will be empty for the new route.

GA4 handles session-level attribution automatically, so for standard GA4 tracking you do not need to manually forward UTMs on every route change. If you need UTMs for custom analytics, capture them in sessionStorage on the first navigation:

router/index.js — capture UTMs on first navigation
const UTM_KEYS = [
  'utm_source', 'utm_medium', 'utm_campaign',
  'utm_term', 'utm_content'
]
let utmsCaptured = false

router.afterEach((to) => {
  if (!utmsCaptured) {
    UTM_KEYS.forEach(key => {
      const val = to.query[key]
      if (val) sessionStorage.setItem(key, String(val))
    })
    utmsCaptured = true
  }

  window.gtag('event', 'page_view', {
    page_path: to.fullPath
  })
})

The utmsCaptured flag ensures the sessionStorage write only happens once per session, on the first navigation where UTMs are present. Reading from sessionStorage later — on form submissions or conversion events — lets you attach original UTM attribution to any custom analytics events even after multiple route changes.

Pre-launch validation with mlz preflight

After applying the router fix, confirm the full URL chain is clean before launch. mlz preflight validates the redirect chain, SSL, Open Graph tags, Twitter Card metadata, and UTM parameter integrity in a single command:

mlz preflight --url "https://your-spa.com/landing" --source "linkedin" --medium "social" --campaign "q3-launch"
mlz preflight — pre-launch SPA campaign validation
{
  "ready": true,
  "tracked_url": "https://your-spa.com/landing?utm_source=linkedin&utm_medium=social&utm_campaign=q3-launch",
  "checks": [
    { "check": "ssl",          "status": "pass", "message": "URL uses HTTPS." },
    { "check": "resolution",   "status": "pass", "message": "Destination responded with 200." },
    { "check": "redirects",    "status": "pass", "message": "No redirects detected." },
    { "check": "og_tags",     "status": "pass", "message": "All essential Open Graph tags present." },
    { "check": "twitter_card", "status": "pass", "message": "Twitter Card tags configured." }
  ],
  "summary": { "total": 12, "passed": 12, "warnings": 0, "failed": 0 },
  "recommendation": "All checks passed. Campaign link is ready to publish."
}

A clean preflight result means the landing URL is solid at the infrastructure level. The router tracking fix you applied handles the client-side attribution. Add mlz preflight to your pre-launch checklist for every campaign — especially when deploying new SPA routes or updating Angular/Vue Router configuration, which can inadvertently affect how landing pages serve responses.

Framework quick reference

Framework Router event hook Key property Works with SSR?
Angular Router events Observable + NavigationEnd filter event.urlAfterRedirects Client-only (use isPlatformBrowser guard)
Vue 3 + Vue Router 4 router.afterEach() to.fullPath Client-only (check typeof window)
Vue 2 + Vue Router 3 router.afterEach() to.fullPath Client-only (check typeof window)
Nuxt 3 useRouter().afterEach() in plugin to.fullPath Use .client.js plugin suffix

In all cases: set send_page_view: false in the gtag('config', ...) call to avoid doubling the first event when the router fires immediately on load. For UTM persistence across routes, GA4's session-level attribution handles it automatically — manual sessionStorage persistence is only needed for custom analytics pipelines that aren't GA4.

Frequently asked questions

Does gtag.js automatically track route changes in Angular or Vue?
No. gtag.js hooks into browser navigation events at script load time. Angular Router and Vue Router use the HTML5 History API pushState internally, but the GA4 configuration's automatic page tracking does not reliably intercept these SPA navigation events by default. The only reliable approach is to manually fire page_view in the Angular NavigationEnd subscriber or Vue router.afterEach callback on every navigation, including the first.
Will the manual page_view event duplicate the initial page load tracking?
Only if you leave GA4's automatic page_view enabled. Set send_page_view: false in your gtag('config', 'G-XXXXXXXXXX', { send_page_view: false }) call to suppress the automatic initial event, then fire it manually in your router hook on every navigation including the first. This gives you full control and avoids any duplication in the GA4 DebugView.
How do UTM parameters appear in GA4 when using the router hook?
GA4 associates UTM parameters with session data set on the first page_view event in the session. Subsequent page_view events within the same session inherit the session's UTM attribution automatically — you do not need to pass UTMs as parameters on every router-triggered page_view call, only on the first. This is GA4's standard session attribution model and works the same whether the first event fires from a full page load or from the first router callback invocation.
Does this fix work for Angular's hash router or Vue's hash mode?
Yes. Both Angular's HashLocationStrategy and Vue Router's createWebHashHistory trigger the same router navigation events. The NavigationEnd subscriber and afterEach callback fire regardless of whether history mode or hash mode is used. The event.urlAfterRedirects and to.fullPath properties include the hash fragment when using hash-based routing.
How is this different from the Next.js SPA tracking fix?
The root cause is identical: SPA routing does not trigger GA4 page_view automatically. The implementation differs by framework. Next.js App Router uses usePathname and useSearchParams hooks inside a client component wrapped in Suspense. Angular uses the Router events Observable filtered to NavigationEnd. Vue uses router.afterEach, which is also the pattern for Vue 2 and Nuxt. See UTM Parameters Not Tracked in Next.js SPA for the Next.js implementation.

Validate your SPA campaign links before fixing the routing

Run mlz check first to confirm UTMs survive the redirect chain — then apply the Angular Router or Vue Router fix to capture them on every client-side navigation.

npm install -g missinglinkz

Then: mlz check "https://your-spa.com/landing?utm_source=linkedin&utm_medium=social&utm_campaign=q3"

Recommended posts