Module 07 · Single-page applications

The page
never actually reloads.

React, Vue, Angular, Next.js — your tracking has to invent the concept of "a new page" because the browser never does. Get this wrong and your data is meaningless.

Reading50 min
LabSandbox
DifficultyHard
Pre-reqModules 03–06

1 · The SPA problem

A traditional multi-page site loads new HTML on every navigation. AppMeasurement loads with each new document; s.t() fires from a clean state. There is no ambiguity about "what page is this?" — the browser answered it for you.

A single-page application loads once. From then on, the user clicks links, the URL changes via pushState, the visible content swaps via JavaScript — but the page itself never reloads. window.s is still the same object it was twenty navigations ago, carrying every variable that was ever set on it.

Traditional site                       SPA
────────────────                       ────
1. Browser loads /home                 1. Browser loads /
   AppMeasurement init                    AppMeasurement init
   s.t() — pageName="Home"                s.t() — pageName="Home"

2. User clicks → /products             2. User clicks → /products
   Browser loads new HTML                 React updates the DOM
   AppMeasurement re-initialises          ⚠ No reload, no init
   s.t() — pageName="Products"            ⚠ No s.t() unless YOU fire one
                                          ⚠ s still has stale data

Two things you must build by hand:

  1. A mechanism to detect that a navigation has occurred.
  2. A mechanism to reset stale variables before the next hit fires.

2 · Virtual page views

The term is industry standard. A "virtual page view" is a s.t() hit fired manually in response to a SPA route change, mimicking what would have happened naturally on a multi-page site.

// Inside a route-change handler
function trackVirtualPageView(routeData) {
  // 1. Reset stale state
  s.clearVars();           // wipe every standard variable
  // 2. Repopulate from the current view
  s.pageName = routeData.title;
  s.channel  = routeData.section;
  s.eVar4    = routeData.productSku;  // if any
  s.events   = routeData.events;      // if any
  // 3. Fire
  s.t();
}

s.clearVars() is the helper AppMeasurement ships for exactly this purpose. It zeros every eVar, prop, event, hier, list, and standard variable on the s object, giving you the equivalent of a fresh page-load state. Call it first, set variables second, fire the hit third.

If you take one thing from this module

Always s.clearVars() before each virtual page view. Always. The single most common SPA bug is "the previous page's variables are showing up on the next page" — caused by skipping this step.

3 · Router triggers — how to detect a navigation

Three patterns, in order of preference:

3.1 — Framework router event

If you're inside a framework, use its router. Every modern router emits a "route changed" event or hook:

// React Router v6
import { useLocation } from "react-router-dom";
import { useEffect } from "react";

useEffect(() => {
  trackVirtualPageView({ /* derive from location */ });
}, [location.pathname]);

// Vue Router
router.afterEach((to) => {
  trackVirtualPageView({ title: to.meta.title, ... });
});

// Next.js (App Router)
"use client";
import { usePathname } from "next/navigation";
useEffect(() => { trackVirtualPageView(...); }, [pathname]);

3.2 — Custom event on the data layer

If you're following the Adobe Client Data Layer pattern (module 02), the framework pushes an event when a new view loads, and Launch listens. This decouples implementation from the framework:

// Inside the SPA framework
adobeDataLayer.push({
  event: "pageView",
  page: { name: "Products: Telescopes", section: "shop" }
});

Launch has a "Data Layer / Event Pushed" event type that catches this. Your tracking code lives in Launch, not the SPA codebase — much better for cross-team ownership.

3.3 — History API monkey-patch (last resort)

If you have no router and no data layer, you can patch history.pushState globally. This is brittle and breaks if multiple scripts do the same thing — avoid it if you can.

(function () {
  const _push = history.pushState;
  history.pushState = function () {
    const ret = _push.apply(this, arguments);
    window.dispatchEvent(new Event("locationchange"));
    return ret;
  };
  window.addEventListener("popstate", () => {
    window.dispatchEvent(new Event("locationchange"));
  });
})();

window.addEventListener("locationchange", () => {
  trackVirtualPageView({ /* derive from location.pathname */ });
});

4 · The variable-leak bug

This bug ships to production at almost every SPA implementation. The pattern:

// Route 1 — campaign landing
s.eVar15 = "google-paid-summer";
s.pageName = "Landing: Summer";
s.t();

// Route 2 — same user navigates to PDP
s.pageName = "PDP: Astro-3000";
s.eVar4 = "ASTRO-3000";
s.t();
// ⚠ eVar15 is STILL on s. It will fire again. And again.
// Every subsequent hit credits "google-paid-summer" until the tab closes.

// Route 3 — homepage
s.pageName = "Home";
s.t();
// ⚠ eVar15 fires again. eVar4 fires again.

Two consequences. First, your campaign report attributes the user's entire session — including purchases — to "google-paid-summer," which is technically what you wanted, but you got there by luck, not design. Second, eVar4 fires on every hit including the homepage, so the eVar4 report shows "ASTRO-3000 viewed on the homepage" — meaningless noise.

The fix is s.clearVars() at the top of every route handler. Variables you want to persist (campaign, login state) are re-set deliberately on the next hit; everything else falls away.

5 · The SPA-safe data layer

Module 02 covered the data layer in general. SPAs add a constraint: the data layer must be versioned per route, not a global mutable object.

Bad — single global, mutated in place:

window.digitalData.page.name = "New Page";
window.digitalData.product.sku = "ASTRO-3000";
// Now what does the next route do?
// Mutate again? Reset to {}? Half-clear? Every team will do it differently.

Good — event queue, push new state per route:

adobeDataLayer.push({
  event: "pageView",
  page: { name: "Products: Telescopes" },
  product: null  // explicit null clears prior product context
});

The Adobe Client Data Layer library (and similar libraries) computes a merged view of all pushes — but emits an event for every push. Launch listens to the event, reads the merged state, and fires. This pattern survives async routes, code-split chunks, and developer hand-offs.

6 · Launch rules for SPA

Here's a concrete Launch configuration for a SPA tracked via the Adobe Client Data Layer:

Rule: SPA · Virtual Page View

Events:
  Adobe Client Data Layer — Data Pushed
  Specific event: "pageView"

Conditions:
  (none — the event itself is the trigger)

Actions:
  1. Custom Code — s.clearVars()
  2. Adobe Analytics — Set Variables
       pageName  = %page.name%
       channel   = %page.section%
       eVar4     = %product.sku%   (if defined)
       events    = %page.events%   (if defined)
  3. Adobe Analytics — Send Beacon (Page View)

Note the order: clear, set, send. Data elements like %page.name% reference the merged data layer state, which the ACDL has just updated before Launch fired. Everything is deterministic.

6.1 — Link tracking on SPA

Link rules don't change much for SPA — s.tl() works the same. But you must remember that the s object carries page-state from the most recent virtual page view. linkTrackVars is even more critical here than on multi-page sites: if you don't whitelist, the link hit picks up nothing; if you don't reset stale state, it picks up everything.

7 · Framework-specific notes

7.1 — React

React Router v6 doesn't fire its useLocation hook on the initial render in the same way as on subsequent renders. Pattern: fire your first page view from a top-level effect on mount; from then on, depend on location.pathname and the effect re-runs on changes.

React 18 Strict Mode double-invokes effects in development. If your virtual page view fires twice locally but once in production, that's why — don't fight it, but verify with a production build.

7.2 — Next.js

App Router and Pages Router behave differently. App Router renders Server Components first; the client-side data layer must wait until hydration. The cleanest pattern: a client component near the layout root that listens to usePathname and pushes to the data layer.

Don't fire page views from Server Components — the data layer isn't there.

7.3 — Vue

Vue Router's afterEach hook is reliable. The gotcha: dynamic page metadata (titles, SKUs) often isn't ready at afterEach time — it's still being fetched. Either delay the virtual page view until the data is in, or fire two hits: a synthetic "view started" and a later "view enriched." Most teams pick the first option.

7.4 — Angular

Use the Router.events observable and filter to NavigationEnd. Be careful about lazy-loaded routes — the route module may not be fully bootstrapped at NavigationEnd time on a cold load.

8 · Checkpoint

You should now be able to:

  • Explain why virtual page views exist.
  • Write a route-change handler that fires a clean AA page view.
  • Diagnose a "stale eVar fires on every page" bug.
  • Build a Launch rule listening to a data-layer event.
  • Pick a router-hook strategy for React, Vue, or Next.

Up next: Web SDK / Alloy / XDM — Adobe's modern alternative to AppMeasurement, where the data model and the transport are different.