Published Aug 16, 2026
SPA Analytics Architecture: Page Views, Events, Timers, and State Cleanup
A practical reference architecture for SPA analytics that treats every virtual page as a lifecycle: commit the route, close the previous scope, send one page_view, attach events to a stable context, and clean up timers, requests, ads, widgets, and user state.
Category: Analytics & Conversion Tracking · By metricfixer Expert Team
A single-page application loads one HTML document but can show dozens of user-visible pages. Reliable measurement therefore needs a virtual-page lifecycle: the application confirms a route, closes the previous page scope, publishes one complete page_view context, starts route-specific effects, and removes everything that must not survive the next navigation.
Practical default: let the application router announce a successful virtual-page commit. Use that signal to dispose of the old route, create an immutable page snapshot, push one virtual_page_view event, and then start timers, observers, ads, widgets, and event listeners inside a disposable route scope. Treat GTM History Change as a fallback detector—not as the complete architecture.
Executive summary
The central mistake in SPA analytics is to treat the application as one long-lived page with a few extra URL changes. The browser document is long-lived, but the measurement model should contain many short-lived virtual pages. Each virtual page needs a clear beginning, a stable context, and an end.
A robust implementation follows six rules:
- Count a page only after navigation succeeds. A click,
pushState()call, loading indicator, guard check, or pending route is not yet a new page. Cancelled and redirected navigations must not create intermediate page views. - Choose one owner for
page_view. Do not combine GA4 automatic history measurement, a GTM History Change tag, a framework plugin, and custom application code. One committed route should produce one page view. - Publish page and event data atomically. Values pushed after an event cannot repair the hit that has already fired. Required route metadata must exist before
virtual_page_view; event-specific values must travel in the same data-layer message as the event. - Give every virtual page a disposable scope. Timers, observers, subscriptions, pending requests, injected DOM, ad slots, and widget instances must register cleanup functions and be stopped when the route ends.
- Separate global state from route state. Consent, vendor SDK loaders, and authentication live across routes. Page title, content ID, timers, and widgets do not. A logout must explicitly clear
user_idfor subsequent events. - Make route commits idempotent. Repeated router notifications, framework development checks, URL normalization, and component re-renders must not create duplicate page views for the same semantic page.
Google’s current SPA guidance says accurate measurement requires a page view for each screen and a correct referrer, warns against combining GTM tracking with GA4 automatic history tracking, and explains that correct virtual page views allow user_engagement, click, and scroll events to be attributed to the active screen. The architecture in this article extends that page-view requirement into the rest of the application lifecycle.

Why a History Change trigger is not an architecture
A history listener answers one narrow question: did the browser URL or history state change? It does not necessarily know whether the destination loaded successfully, whether a route guard cancelled navigation, whether a redirect replaced the first URL, whether the page title is final, whether required product data has arrived, or which resources belong to the old screen.
This distinction explains the most common production symptoms:
| Symptom | Architectural cause | Corrective pattern |
|---|---|---|
| Tags work only on the first load | The implementation listens to document-load triggers such as DOM Ready or Window Loaded, but the document does not reload during SPA navigation. | Subscribe once to the framework router and publish a custom event after every successful semantic route commit. |
No page_view after a route change | The initial Google tag fired, but no component owns subsequent virtual pages. | Choose one page-view owner and make it handle both the initial route and later commits. |
| A timer fires on the next page | The timer belongs to the document rather than to the virtual-page scope. | Register its cancellation function with the active route and pause visible-time timers when the tab is hidden. |
| A variable appears after the event | Asynchronous application state and the data-layer event have no readiness contract. | Define minimum page metadata, include event parameters in the event message, and model later results as later events. |
| An ad or widget remains after navigation | The global SDK lifetime and the route-instance lifetime were treated as the same thing. | Load the SDK once, but destroy each route-specific slot, iframe, observer, listener, and DOM mount. |
user_id remains after logout | Authentication was set once and never updated in the long-lived document. | Keep auth as global mutable state and send JavaScript null on logout before subsequent events. |
GTM’s History Change trigger is still useful where the application cannot be modified. Google documents it as a supported SPA setup. But it should be understood as a URL-change signal. It does not provide the teardown registry, event readiness, authentication updates, or vendor cleanup needed for a complete SPA measurement system.
First define what counts as a virtual page
A browser URL change and a measurement page boundary are not always the same thing. The team should document a semantic rule before writing triggers. A useful test is: would this state deserve its own page title, content identity, entry in a navigation path, and meaningful page-level reporting?
| Application transition | Default measurement | Why |
|---|---|---|
| Home → product detail → checkout | New page_view for each committed route | Each route represents a distinct content and reporting context. |
| Browser Back or Forward to another route | New page_view | The user has returned to a distinct virtual page even though the document is unchanged. |
| Redirect chain | One page_view for the final successful route | Intermediate targets were not stable user-visible pages. |
| Cancelled guard, a failed loader that retains the current screen, or superseded navigation | No destination page_view | The previous page remains active until a replacement screen is actually committed. If the router commits a dedicated error boundary, that error screen can be measured as its own page. |
| Sort, filter, or view-mode query parameter | Usually an interaction event | The page identity often remains the same. Count it as a page only when the business deliberately treats the resulting state as a separate page. |
| Hash anchor, accordion, or tab | Usually an interaction event | History APIs can change while the primary page context stays unchanged. |
| Modal or drawer | Usually view_modal or another event | A modal should become a page only if it is independently addressable and replaces the primary content context. |
| Checkout or form step | Either virtual page views or named step events | Both models can work, but mixing them inconsistently creates inflated funnels. Choose one model per flow. |
| Data refresh on the same route | No new page view by default | Refreshing server data is not automatically a new user-visible page. This is especially relevant to router.refresh() in Next.js. |
| Rendered 404 or error screen | A page view with explicit status metadata | The user saw a real screen. Direct requests should also return the technically correct HTTP status; see metricfixer’s 404 page architecture guide. |
The practical result is a semantic navigation key. It may use a route name plus meaningful parameters—for example product:SKU-123—rather than every raw hash and query-string mutation. Keep this internal deduplication key separate from page_location, which should represent the actual page URL sent to analytics.
The “hash is usually an interaction” rule applies to ordinary anchors and tabs. If the application uses a hash-based router and the fragment is the actual route, include that route fragment in both the semantic key and the measured location.
The complete virtual-page lifecycle
Reference flow: application bootstrap → navigation requested → route guards/loaders run while the old page stays active → final route succeeds → old route scope is closed → new page metadata is snapshotted → one virtual_page_view is published → Google tag context is updated → GA4 and other vendor page calls run → route-scoped timers, events, ads, requests, and widgets start → the next successful navigation repeats the cycle.
Phase 0: bootstrap the document once
The application should perform these tasks once per real document load:
- initialize
window.dataLayerwithout overwriting an existing queue; - apply default consent before tags that depend on it, then process consent updates globally;
- load long-lived vendor SDKs once;
- establish the current authentication state;
- install one router bridge;
- decide which system owns all initial and subsequent page views.
Consent is global application state, not a route effect. A route change should not recreate the CMP or reset a remembered decision. The tag and consent chain should be validated separately; metricfixer’s Consent Mode v2 diagnostics guide explains why a fired tag alone does not prove the complete consent flow is correct.
Phase 1: navigation is requested
Keep the current virtual page active while the new navigation is pending. This matters because guards, loaders, network failures, and newer navigations can cancel the first request. Closing the old page at link click or NavigationStart creates a gap: events that occur during the loading state have no valid page context, and cancelled navigation undercounts the page the user never actually left.
A loading indicator may start here, and the router may cancel stale data requests. The analytics boundary should wait.
Phase 2: commit only the final successful route
The router-specific success signal is the correct boundary:
- React Router: the committed
locationrendered by the router; - Vue Router:
router.afterEach()when the thirdfailureargument is empty; - Angular:
NavigationEnd, usingurlAfterRedirects; - Next.js App Router: a client component observing
usePathname()and relevantuseSearchParams()values; - Next.js Pages Router:
routeChangeComplete.
At this point, required analytics metadata must be ready. Prefer route configuration, router parameters, loaders, or resolvers over scraping rendered text from the DOM. A title that is essential to the page view should be resolved before the event; recommendations, inventory badges, or other nonessential data can arrive later.
Phase 3: close the previous page scope
The old page must become inactive before the new page starts. Mark it closed first so late promises and callbacks cannot send events under an obsolete page. Then:
- abort route-owned requests with
AbortController; - clear
setTimeout,setInterval, andrequestAnimationFramework; - remove DOM and window listeners;
- disconnect
IntersectionObserver,MutationObserver, andResizeObserverinstances; - unsubscribe from observables, stores, WebSockets, and event buses;
- destroy ad slots, video players, maps, chat instances, and other vendor widgets;
- remove route-owned injected nodes, body classes, and temporary global values.
Do not send a custom “engagement end” event merely to reproduce GA4 behavior. Google says that correctly implemented virtual page views allow GA4 to send user_engagement as the user moves between virtual pages. A custom virtual_page_end is useful only when a first-party warehouse or a specific business calculation genuinely needs it.
Phase 4: create and publish the new page snapshot
Create a unique page_instance_id and an immutable snapshot containing the new route’s identity. The snapshot should be complete enough that every event on the route can carry the same page context. A unique instance ID is valuable in raw logs or BigQuery, but it is high-cardinality data and normally should not be registered as a GA4 custom dimension.
Publish one custom application event such as virtual_page_view. GTM, GA4, ad platforms, testing tools, and first-party collectors can all subscribe to the same committed route. This prevents each vendor from inventing a different route detector.
Phase 5: activate route-specific effects
Only after the page context exists should the application start visible-time timers, impression observers, ad requests, widget instances, or route-specific subscriptions. Each setup function must return—or register—its inverse cleanup operation.
Phase 6: repeat, hide, or shut down
The next successful navigation closes the current scope and opens another. For a real document exit, the browser’s transition to visibilityState === 'hidden' is a more reliable point for small first-party end-of-session payloads than legacy unload logic. Use navigator.sendBeacon() only for small analytics or diagnostic data that your own endpoint truly needs; normal GA4 page and engagement behavior should remain in the supported tag implementation.
Separate global, route, and event state
| State level | Examples | Lifetime | Reset rule |
|---|---|---|---|
| Global document state | Consent state, loaded SDKs, client/session identifiers, authentication state, current user_id, router subscription | Across virtual pages | Change only on a real global event such as consent update, login, logout, SDK shutdown, or document replacement. |
| Virtual-page state | page_instance_id, location, title, route name, page type, content ID, route requests, timers, observers, ad slots, widget instances | One committed route | Dispose on the next successful semantic route or explicit page-scope shutdown. |
| Event state | Button label, form ID, search term, item, value, currency, error code | One event message | Do not rely on a later push to clear or complete it. Send the complete event payload at the moment of the event. |
The application store should be the source of truth. The data layer is a message bus for tags, not a replacement state manager. Google documents that GTM processes data-layer messages in first-in, first-out order and fires tags for an event before moving to the next message. Therefore, a value that appears in a later asynchronous push is not part of the earlier hit.
A vendor-neutral virtual-page controller
The following reference controller provides four useful guarantees:
- one active virtual-page scope;
- deduplication by a semantic
navigationKey; - a complete page snapshot on every event;
- an
AbortSignaland cleanup registry for route-owned work.
It is intentionally not tied to React, Vue, Angular, Next.js, GA4, or a particular CMP. Framework adapters call openPage() only after a successful route commit; GTM listens for the resulting events.
function publish(message) {
if (typeof window === 'undefined') return;
window.dataLayer = window.dataLayer || [];
window.dataLayer.push(message);
}
function createPageInstanceId() {
if (globalThis.crypto?.randomUUID) {
return globalThis.crypto.randomUUID();
}
return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
function userSnapshot(auth) {
const user = { auth_state: auth.state };
// Omit user_id until a user has actually signed in.
if (auth.userId !== undefined) {
user.user_id = auth.userId;
}
return user;
}
export function createAnalyticsLifecycle() {
let activePage = null;
let auth = {
state: 'anonymous',
userId: undefined,
hasSignedIn: false,
};
function pageSnapshot(page) {
return {
page_instance_id: page.pageInstanceId,
location: page.location,
title: page.title,
route_name: page.routeName,
page_type: page.pageType,
content_id: page.contentId ?? null,
language:
page.language ??
(typeof document !== 'undefined'
? document.documentElement.lang || null
: null),
status: page.status ?? 'success',
};
}
function pushEvent(page, eventName, eventData = null, ecommerce) {
// GA4 ecommerce implementations commonly clear the previous object first.
if (ecommerce !== undefined) {
publish({ ecommerce: null });
}
publish({
event: eventName,
page: pageSnapshot(page),
user: userSnapshot(auth),
event_data: eventData,
...(ecommerce !== undefined ? { ecommerce } : {}),
});
}
function closePage(page, reason = 'route_change') {
if (!page || page.closed) return;
// Mark the scope closed before callbacks can produce late events.
page.closed = true;
page.abortController.abort(reason);
for (const cleanup of [...page.cleanups].reverse()) {
try {
cleanup();
} catch (error) {
console.error('SPA analytics cleanup failed', error);
}
}
page.cleanups.clear();
if (activePage === page) {
activePage = null;
}
}
function openPage(input) {
// Makes router notifications and React development re-runs idempotent.
if (
activePage &&
!activePage.closed &&
activePage.navigationKey === input.navigationKey
) {
return activePage.api;
}
const previousPage = activePage ? pageSnapshot(activePage) : null;
closePage(activePage, 'route_change');
const page = {
...input,
pageInstanceId: createPageInstanceId(),
previousPageLocation: previousPage?.location ?? null,
abortController: new AbortController(),
cleanups: new Set(),
closed: false,
api: null,
};
const api = {
get signal() {
return page.abortController.signal;
},
addCleanup(cleanup) {
if (typeof cleanup !== 'function') {
throw new TypeError('cleanup must be a function');
}
if (page.closed) {
cleanup();
return () => {};
}
page.cleanups.add(cleanup);
return () => page.cleanups.delete(cleanup);
},
track(eventName, eventData = null, ecommerce) {
if (page.closed) return;
pushEvent(page, eventName, eventData, ecommerce);
},
close(reason = 'manual') {
closePage(page, reason);
},
};
page.api = api;
activePage = page;
publish({
event: 'virtual_page_view',
page: pageSnapshot(page),
navigation: {
previous_page_location: page.previousPageLocation,
},
user: userSnapshot(auth),
event_data: null,
});
return api;
}
function track(eventName, eventData = null, ecommerce) {
activePage?.api.track(eventName, eventData, ecommerce);
}
function setUser(userId) {
if (typeof userId === 'string' && userId.length > 0) {
auth = {
state: 'authenticated',
userId,
hasSignedIn: true,
};
} else if (userId === null && auth.hasSignedIn) {
auth = {
state: 'anonymous',
userId: null,
hasSignedIn: true,
};
} else {
return;
}
publish({
event: 'auth_state_changed',
page: activePage ? pageSnapshot(activePage) : null,
user: userSnapshot(auth),
event_data: null,
});
}
return {
openPage,
track,
setUser,
closeActivePage: (reason = 'manual') => closePage(activePage, reason),
};
}
The cleanup registry is deliberately idempotent. Closing a scope twice has no effect, and an asynchronous resource that tries to register after the page has already closed is cleaned immediately. This is important when route loaders, widgets, and framework effects finish in an unexpected order.
GA4 and GTM: one page-view owner, one event sequence
For a controlled application integration, the cleanest model is for the application to own the virtual-page signal and for GTM to own vendor delivery.
| Layer | Recommended configuration | Reason |
|---|---|---|
| Application | Push one virtual_page_view for the initial committed route and every later committed semantic route. | The router knows success, redirects, failures, route metadata, and teardown boundaries. |
| GA4 Enhanced Measurement | Disable “Page changes based on browser history events” when GTM/manual SPA page views are used. | Google explicitly warns that combining automatic history measurement with GTM tracking can double-count pages. |
| Initial Google tag | Set send_page_view to false when the application owns the initial page view as well as later views. | Otherwise the initial configuration call can send one page view and the application can send a second. |
| GTM trigger | Use a Custom Event trigger for virtual_page_view. | The event represents a successful semantic page, not merely a raw history mutation. |
| Google tag update | As a setup tag, update page_location, page_title, and update=true. | Google’s current GTM SPA tutorial uses this sequence so the context is updated before the event without sending an immediate duplicate page view. |
| GA4 Event tag | Send page_view after the update tag succeeds. | The page event sees the correct virtual location and title. |
| Other vendors | Map the same virtual_page_view to each platform’s supported page or route call. | All platforms receive the same committed page boundary. |
Do not automatically map GTM’s built-in Referrer variable to every virtual page. In a long-lived SPA document, document.referrer represents the external document that opened the application; it does not become the previous virtual URL. Google’s current GTM SPA tutorial omits page_referrer from the update tag so GA4 can populate the previous virtual location for pathing. Keep previous_page_location in the application event for first-party diagnostics and verify the actual GA4 values in DebugView.
A GTM History Change implementation remains a reasonable fallback when engineering cannot publish a custom event. In that case, filter nonsemantic changes, suppress duplicate history events, wait until the title and route data are ready, and build cleanup in the application or widget layer. The trigger itself cannot “unfire” a previous Custom HTML tag or remove an iframe that a vendor created.
Events and values that arrive late
Every event should carry the page snapshot and the values known at the moment the event occurs. Do not push an event and expect a promise, DOM mutation, or later data-layer message to enrich the already-dispatched hit.
// Wrong: the event is published before the asynchronous value exists.
window.dataLayer.push({
event: 'form_submit',
event_data: { form_id: 'demo' },
});
fetch('/api/lead-score')
.then((response) => response.json())
.then((result) => {
window.dataLayer.push({ lead_score: result.score });
});
// Correct: send the values that belong to this event in the same message.
window.dataLayer.push({
event: 'form_submit',
page: currentPageSnapshot,
event_data: {
form_id: 'demo',
form_type: 'enterprise',
},
});
// If qualification happens later, it is a new business event.
window.dataLayer.push({
event: 'lead_qualified',
page: currentPageSnapshot,
event_data: {
form_id: 'demo',
lead_score: 87,
},
});
For a virtual page, define a minimum readiness contract. Typical required fields are location, title, route_name, page_type, and a primary content identifier when the page is entity-specific. Obtain these from route configuration, parameters, loaders, or resolvers. Do not delay a page view for data that is not part of its identity.
If noncritical context arrives later, update the application state for future events or send a separate event such as product_inventory_loaded, recommendations_viewed, or lead_qualified. A later page_context_updated message can update future tag behavior, but it does not retroactively change an earlier page_view. Sending a second page view merely to patch a title or category inflates reporting.
For ecommerce, clear the previous ecommerce object and then push the complete new ecommerce event. Do not reset the entire dataLayer as a routine route-change strategy: consent, global identifiers, and other tag state may live there. Use fixed schemas, complete event payloads, and explicit null values for nullable route fields instead.
Timers should measure visible time and belong to a route
A simple setTimeout(..., 30000) measures wall-clock time since setup. It can fire while the tab is hidden, after the user has moved to another virtual page, or after the component that created it has disappeared. Browsers also throttle background timers, so their firing time is not a reliable measure of attention.
GA4 already measures active engagement when virtual page views are implemented correctly. Custom timers should therefore represent a business rule—such as “30 visible seconds on this article”—rather than a second imitation of GA4 engagement.
The following helper counts only time while the document is visible and returns a cleanup function that the current page scope can own:
export function scheduleVisibleTimeout(callback, delayMs) {
let remaining = Math.max(0, delayMs);
let startedAt = null;
let timeoutId = null;
let destroyed = false;
function pause() {
if (startedAt === null) return;
remaining = Math.max(0, remaining - (performance.now() - startedAt));
startedAt = null;
clearTimeout(timeoutId);
timeoutId = null;
}
function finish() {
if (destroyed) return;
if (document.visibilityState !== 'visible') {
pause();
return;
}
destroyed = true;
startedAt = null;
timeoutId = null;
document.removeEventListener('visibilitychange', onVisibilityChange);
callback();
}
function resume() {
if (
destroyed ||
startedAt !== null ||
document.visibilityState !== 'visible'
) {
return;
}
startedAt = performance.now();
timeoutId = window.setTimeout(finish, remaining);
}
function onVisibilityChange() {
if (document.visibilityState === 'visible') {
resume();
} else {
pause();
}
}
function destroy() {
if (destroyed) return;
destroyed = true;
clearTimeout(timeoutId);
document.removeEventListener('visibilitychange', onVisibilityChange);
}
document.addEventListener('visibilitychange', onVisibilityChange);
onVisibilityChange();
return destroy;
}
const pageScope = analytics.openPage(pageDefinition);
pageScope.addCleanup(
scheduleVisibleTimeout(() => {
pageScope.track('content_30_seconds_visible');
}, 30_000),
);
Use performance.now() for elapsed time because it is monotonic. Pause on visibilitychange, cancel on route cleanup, and fire the threshold once. For complex reading-time logic, combine visible time with an IntersectionObserver for the relevant content region, then disconnect the observer at route end.
Ads, widgets, and injected DOM need two lifetimes
Third-party integrations usually contain two different objects:
- a global loader or SDK that should normally load once per document;
- a route instance—slot, iframe, player, map, chat mount, or observer—that must be destroyed when its page disappears.
Removing the visible container is not always enough. A vendor may retain global event listeners, callbacks, targeting values, timers, network connections, or references to detached DOM nodes. Use the vendor’s supported destroy(), unmount(), dispose(), unsubscribe, or slot-destruction API, then remove any route-owned DOM.
Google Publisher Tag’s SPA guidance illustrates this division clearly: load GPT once, control ad requests and targeting intentionally, and call destroySlots() for slots whose containers are removed. The same lifecycle principle applies to other ad servers and embedded tools even though the exact API differs.
GTM does not provide a universal reverse action for Custom HTML. If a tag injects a script, iframe, or listener on the first route, another trigger does not automatically undo it. For integrations that must disappear cleanly, put instance creation behind an application adapter that returns a cleanup function, or use a vendor template/API that exposes lifecycle control.
user_id is global state—and logout is an update
In a traditional multi-page site, a fresh document often hides stale authentication mistakes. In an SPA, the same Google tag and JavaScript state can survive login, account switching, and logout. The implementation must update identity explicitly.
Google’s current GA4 guidance distinguishes three states:
- the user has never signed in: omit
user_id; - the user is signed in: send the stable internal identifier;
- the user signed out after being signed in: send JavaScript
null.
Do not send an empty string or the literal text "null". Do not use an email address, phone number, or other directly identifying value as the analytics user ID. On login or logout, update the Google tag through the supported configuration mechanism—gtag('set', ...) in a direct gtag.js implementation or a sequenced native Google tag update in GTM—before sending the related login/logout event and before the next page view.
The reference controller publishes auth_state_changed and keeps the new identity state on subsequent application events. In GTM, make the Google tag identity update a setup dependency for any event tag that uses this custom event. This prevents the logout event itself from being sent with the old ID.
Reference architecture for React
React Router exposes the committed location through useLocation(), which its documentation explicitly presents as a place for route-change side effects. The central analytics boundary should resolve stable metadata from a route manifest or data-router metadata, then call the idempotent lifecycle controller.
import { useEffect, useMemo } from 'react';
import { useLocation } from 'react-router';
import { analytics } from './analytics-lifecycle.js';
import { resolveAnalyticsRoute } from './analytics-routes.js';
export function AnalyticsRouteBoundary() {
const location = useLocation();
const page = useMemo(
() => resolveAnalyticsRoute(location),
[location.pathname, location.search, location.hash],
);
useEffect(() => {
if (!page) return;
analytics.openPage({
navigationKey: page.navigationKey,
location: window.location.href,
title: page.title,
routeName: page.routeName,
pageType: page.pageType,
contentId: page.contentId ?? null,
status: page.status ?? 'success',
});
// Intentionally no cleanup here. The next committed navigation closes
// the previous virtual-page scope. The controller deduplicates the
// extra development-only Effect run in React Strict Mode.
}, [page]);
return null;
}
The missing cleanup return in this specific boundary is intentional. React Strict Mode performs an extra development-only setup-and-cleanup cycle to expose unsafe effects. An irreversible page-view send cannot be “unsent” by cleanup. The controller therefore deduplicates the same navigationKey, and the next committed key closes the old virtual page. This keeps development behavior from producing a second page view. A microfrontend or embeddable app that can unmount while the host document remains alive should call closeActivePage('app_unmount') from its host-level shutdown hook.
This does not remove normal React cleanup requirements. Components that create timers, observers, subscriptions, fetches, maps, players, or widgets should return cleanup from their own useEffect(). Use the route scope’s AbortSignal for fetches and register vendor destructors with the scope as an additional safety layer. React’s documentation specifically describes cleanup before changed dependencies and on unmount, and warns about stale asynchronous responses.
Reference architecture for Vue
Vue Router’s global afterEach hook is designed for work such as analytics and receives navigation failures as a third argument. Install the bridge before the app’s initial navigation completes so it covers the first route as well as later ones.
import { nextTick } from 'vue';
import { analytics } from './analytics-lifecycle.js';
import { resolveAnalyticsRoute } from './analytics-routes.js';
export function installAnalyticsRouterBridge(router) {
return router.afterEach(async (to, from, failure) => {
if (failure) return;
const page = resolveAnalyticsRoute(to);
if (!page) return;
// Prefer route meta for the title. Wait for the DOM only when the title
// genuinely depends on a component update.
if (page.waitForDomTitle) {
await nextTick();
}
analytics.openPage({
navigationKey: page.navigationKey,
location: window.location.href,
title: page.title ?? document.title,
routeName: page.routeName,
pageType: page.pageType,
contentId: page.contentId ?? null,
status: page.status ?? 'success',
});
});
}
Prefer to.meta, the route name, parameters, and resolved store data for analytics metadata. Use nextTick() only if a field genuinely depends on the rendered DOM. A DOM title that changes after the page view has fired cannot repair the earlier event.
Vue components should use onUnmounted() to clear manually created timers, listeners, and server connections. Components cached by <KeepAlive> do not necessarily unmount when they leave the screen, so use onDeactivated() to pause route effects and onActivated() to resume or recreate them when appropriate.
Reference architecture for Angular
Angular exposes the complete navigation sequence. NavigationEnd is the event for a successful navigation; NavigationCancel, NavigationError, and skipped navigation should not create the destination page view. Use urlAfterRedirects so only the final URL is measured.
Keep analytics metadata in route configuration instead of reconstructing it from component DOM. Angular supports both route titles and arbitrary route data, including analytics metadata.
export const routes = [
{
path: 'products/:id',
component: ProductPage,
title: 'Product details',
data: {
analytics: {
routeName: 'product',
pageType: 'product_detail',
keyParam: 'id',
},
},
},
];
// Ensure the root-provided bridge is instantiated by the application shell.
export class AppComponent {
analyticsRouterBridge = inject(AnalyticsRouterBridge);
}
A root router bridge can then open the virtual page after successful navigation:
import { isPlatformBrowser } from '@angular/common';
import { DestroyRef, Injectable, PLATFORM_ID, inject } from '@angular/core';
import { Title } from '@angular/platform-browser';
import { NavigationEnd, Router } from '@angular/router';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { filter } from 'rxjs';
import { analytics } from './analytics-lifecycle.js';
function deepestRoute(snapshot) {
let route = snapshot;
while (route.firstChild) route = route.firstChild;
return route;
}
@Injectable({ providedIn: 'root' })
export class AnalyticsRouterBridge {
router = inject(Router);
title = inject(Title);
destroyRef = inject(DestroyRef);
platformId = inject(PLATFORM_ID);
constructor() {
if (!isPlatformBrowser(this.platformId)) return;
this.router.events
.pipe(
filter((event) => event instanceof NavigationEnd),
takeUntilDestroyed(this.destroyRef),
)
.subscribe((event) => {
const route = deepestRoute(this.router.routerState.snapshot.root);
const page = route.data['analytics'];
if (!page) return;
const keyValue = page.keyParam
? route.paramMap.get(page.keyParam)
: null;
analytics.openPage({
navigationKey: keyValue
? `${page.routeName}:${keyValue}`
: page.routeName,
location: window.location.href,
title: route.title ?? this.title.getTitle(),
routeName: page.routeName,
pageType: page.pageType,
contentId: keyValue,
status: page.status ?? 'success',
});
});
}
}
The browser guard prevents analytics code from running during Angular server rendering. takeUntilDestroyed() closes the subscription when its Angular lifecycle ends. For component-specific timers, observers, and widgets, use DestroyRef.onDestroy() or takeUntilDestroyed() at the component level. Resolvers are appropriate for identifiers that are required before page commit, but do not block the route indefinitely for nonessential analytics enrichment.
Reference architecture for Next.js
App Router
In the App Router, router.events is not available. Current Next.js documentation recommends composing usePathname() and useSearchParams() in a Client Component. Place a small persistent analytics component in the root layout and wrap it in <Suspense> where required by the route’s rendering mode.
// app/navigation-analytics.js
'use client';
import { useEffect, useMemo } from 'react';
import { usePathname, useSearchParams } from 'next/navigation';
import { analytics } from './analytics-lifecycle.js';
import { resolveNextAnalyticsRoute } from './analytics-routes.js';
export function NavigationAnalytics() {
const pathname = usePathname();
const searchParams = useSearchParams();
const query = searchParams.toString();
const page = useMemo(
() => resolveNextAnalyticsRoute(pathname, query),
[pathname, query],
);
useEffect(() => {
if (!page) return;
analytics.openPage({
navigationKey: page.navigationKey,
location: window.location.href,
title: page.title,
routeName: page.routeName,
pageType: page.pageType,
contentId: page.contentId ?? null,
status: page.status ?? 'success',
});
}, [page]);
return null;
}
// app/layout.js
import { Suspense } from 'react';
import { NavigationAnalytics } from './navigation-analytics.js';
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>
<Suspense fallback={null}>
<NavigationAnalytics />
</Suspense>
{children}
</body>
</html>
);
}
Keep the route resolver synchronous and semantic. A query change should alter navigationKey only when the product team has defined it as a new page. A same-URL router.refresh() normally refreshes data without creating another page view. Rewrites can make the server source pathname differ from the browser pathname, so verify hydration behavior and use the browser URL as page_location after mount.
Next.js Server Components can provide page metadata and content, but browser analytics still need a small client boundary. Avoid placing the entire layout in a Client Component merely to observe navigation.
Pages Router
The Pages Router still exposes routeChangeComplete. Call the same commit function once for the initial route, subscribe for later successful routes, and unsubscribe when the custom App unmounts:
import { useEffect } from 'react';
import { useRouter } from 'next/router';
import { analytics } from '../lib/analytics-lifecycle.js';
import { resolveNextAnalyticsRoute } from '../lib/analytics-routes.js';
export default function App({ Component, pageProps }) {
const router = useRouter();
useEffect(() => {
if (!router.isReady) return;
const commitRoute = (url) => {
const page = resolveNextAnalyticsRoute(url);
if (!page) return;
analytics.openPage({
navigationKey: page.navigationKey,
location: window.location.href,
title: page.title,
routeName: page.routeName,
pageType: page.pageType,
contentId: page.contentId ?? null,
});
};
commitRoute(router.asPath);
router.events.on('routeChangeComplete', commitRoute);
return () => {
router.events.off('routeChangeComplete', commitRoute);
};
}, [router.isReady, router.events]);
return <Component {...pageProps} />;
}
Because only routeChangeComplete opens a page, cancelled routeChangeError transitions do not create false views. The controller’s semantic key also suppresses duplicate notifications for the same page.
Framework integration comparison
| Framework | Successful route signal | Route metadata source | Cleanup mechanism | Main trap |
|---|---|---|---|---|
| React + React Router | Committed useLocation() value | Route manifest, matches, loader data | useEffect() cleanup plus shared route scope | Duplicate development sends when Strict Mode reruns effects; async data arriving after a page view |
| Vue + Vue Router | router.afterEach() with no failure | to.meta, route name/params, resolved store data | onUnmounted(); onDeactivated() for cached components | Counting failed navigation or assuming a <KeepAlive> component unmounted |
| Angular | NavigationEnd | Route title, data, params, resolvers | takeUntilDestroyed(), DestroyRef | Sending at NavigationStart or measuring a pre-redirect URL |
| Next.js App Router | Changed usePathname() plus relevant search params | Client route manifest/context backed by server data | React effect cleanup plus shared route scope | Looking for removed router.events, counting router.refresh(), or turning a large layout into a Client Component |
| Next.js Pages Router | routeChangeComplete | Page props, route map, router.asPath | Unsubscribe with router.events.off() | Forgetting the initial route or leaving duplicate listeners after hot reload/unmount |
How to test without drawing the wrong conclusion
Test the lifecycle, not only the tag status. GTM Preview and Tag Assistant are valuable, but a “Fired” label proves that browser-side trigger logic ran in that preview configuration. It does not by itself prove that the page context was correct, the request reached the intended property, the production container is published, or the event completed GA4 processing. For the reporting side of this distinction, see metricfixer’s GA4 DebugView, Realtime, and reports lifecycle guide.
A useful manual test sequence
- Start a clean session on the initial route. Confirm exactly one
virtual_page_viewand one GA4page_view. - Navigate through at least three routes, including Back and Forward. Confirm one view per semantic page and the expected previous virtual location.
- Trigger a redirect. Confirm that only the final URL produces a page view.
- Start a navigation that is cancelled by a guard or superseded by another navigation. Confirm that the cancelled destination produces no page view and the old page remains active until the final commit.
- Change a filter, hash, modal, or nonsemantic query parameter. Confirm that the agreed interaction event fires without an unwanted page view.
- Leave a route before its timer threshold. Confirm that no timer event appears on the next route.
- Hide the tab during a visible-time test. Confirm that the hidden interval is not counted.
- Navigate away before a fetch, observer, or widget callback completes. Confirm that the old scope cannot publish a late event under the new page.
- Log in, send a controlled event, log out, and send another. Inspect the event sequence and verify that
user_idis cleared with JavaScriptnullbefore the post-logout event. - Repeat the React test in development Strict Mode. Confirm that the initial committed route still creates one page view.
What to inspect in each tool
| Tool | Inspect | Do not infer automatically |
|---|---|---|
| Router/framework logs | Requested, cancelled, redirected, failed, and successful navigations | That every URL mutation deserves a page view |
| Tag Assistant | Custom event order, data-layer snapshot at that event, consent state, setup-tag sequencing, tag parameters | That the latest value shown after the fact existed when an earlier event fired |
| Browser Network panel | One outbound page request per committed route, correct measurement ID, location, title, status, and consent parameters | That a tag’s “Fired” status guarantees the request completed |
| GA4 DebugView | Event order and page_location/page_referrer changes between virtual pages | That standard reports and registered custom dimensions are already processed |
| Memory/performance tools | Detached nodes, growing listener counts, duplicate SDK instances, surviving observers and timers | That visual removal of a widget means its JavaScript instance was destroyed |
Release checklist
- [ ] There is one documented definition of a virtual page.
- [ ] One system owns both the initial and subsequent
page_viewevents. - [ ] GA4 automatic history page views are disabled when manual GTM/app tracking is active.
- [ ] The router publishes only successful final routes.
- [ ]
navigationKeyignores nonsemantic changes and changes for true new pages. - [ ] Required page metadata exists before
virtual_page_view. - [ ] Every application event carries a stable page snapshot and its own parameters.
- [ ] Route timers pause while hidden and are cancelled on leave.
- [ ] Requests, observers, listeners, subscriptions, ad slots, and widgets have tested destructors.
- [ ] Login updates identity before authenticated events; logout sends JavaScript
nullbefore later events. - [ ] Redirects, errors, cancelled routes, Back/Forward, query changes, and React Strict Mode are covered by tests.
- [ ] A clean production session is tested outside GTM Preview after the container and application release are published.
Recommended ownership model
The most maintainable division of responsibility is:
- The router owns page boundaries. It knows when navigation has actually succeeded.
- The application owns page metadata and cleanup. It knows which data, requests, components, and vendor instances belong to the route.
- The data layer owns delivery contracts. It carries complete, versioned messages to the tag layer.
- GTM owns tag orchestration. It sequences consent-aware vendor configuration and event calls without trying to infer application truth from the DOM.
- GA4 and other platforms own their supported collection semantics. Do not recreate built-in engagement logic with fragile custom timers.
This architecture is more work than adding one History Change trigger, but it solves the entire class of SPA problems at once: missing and duplicate page views, events with stale variables, timers that outlive their pages, leaked widgets, incorrect route referrers, and user identity that survives logout.
Methodology and sources
This article is based primarily on current official documentation from Google Analytics, Google Tag Manager, React, React Router, Vue, Vue Router, Angular, Next.js, MDN, and Google Publisher Tag. The framework examples translate those lifecycle APIs into one vendor-neutral virtual-page model. The supplied metricfixer HTML examples were also used as editorial and structural references for the CMS fragment.
- Google Analytics: Measure single-page applications
- Google Analytics: Measure SPAs with Google Tag Manager
- Google Tag Platform: The data layer
- Google Analytics: Send user IDs
- React: useEffect
- React Router: useLocation
- Vue Router: Navigation guards and global after hooks
- Vue: Composition API lifecycle hooks
- Vue: nextTick
- Angular: Router lifecycle and events
- Angular: Define routes, titles, and route data
- Angular: takeUntilDestroyed
- Angular: DestroyRef
- Next.js App Router: usePathname
- Next.js App Router: useRouter and router events replacement
- Next.js Pages Router: useRouter and router events
- MDN: Page Visibility API
- MDN: visibilitychange event
- MDN: AbortController
- MDN: Navigator.sendBeacon
- Google Publisher Tag: GPT and React
This article provides technical and operational guidance, not legal advice. Analytics, consent, advertising, framework, router, and browser APIs can change, and third-party widgets have vendor-specific lifecycle rules. Adapt the examples to the application’s routing, consent, identity, security, and data-governance requirements, and validate the final implementation in a clean production session.