Skip to content

SPA tracking pitfalls

Single-page apps break most of the analytics assumptions that work fine on traditional sites. The page never reloads. The URL changes without a navigation event. The “page view” is something you have to manufacture. Every CRO project that lands on a React, Vue, or Next site has to retrofit this, usually after the first month of weird funnel data.

Traditional sites: the browser navigates, new HTML loads, the analytics script runs again, a pageview fires.

SPAs: the route changes, the framework swaps the view, no script reruns, no pageview. The analytics tool thinks the user is still on the page they landed on.

The fix is to hook the router and fire a pageview manually on each route change.

// React + react-router
import { useEffect } from 'react'
import { useLocation } from 'react-router-dom'
function PageviewTracker() {
const location = useLocation()
useEffect(() => {
analytics.page({
path: location.pathname,
search: location.search,
title: document.title,
})
}, [location])
return null
}

This sounds obvious. It’s also the single most commonly missing piece on every SPA I’ve audited. Next.js, Nuxt, and SvelteKit each have their own router hook for this, but the pattern is the same.

Frameworks render asynchronously. document.title might not be updated yet when the route change fires. The pageview captures the previous page’s title and the new page’s path, which makes the warehouse data confusing for years.

Fix: fire the pageview after the title has actually updated, either by waiting a tick or by hooking into whatever sets the title (most apps use react-helmet, next/head, or similar - those expose a callback or rerender hook).

Two events look identical to the user but are different in the code:

  1. User clicks an internal link - the router intercepts, no full page load.
  2. User clicks an external link - the browser navigates away, your JS unloads.

Tracking the click before the navigation matters in the second case. If you wait for the new page to load, your event never fires because your JS isn’t running there.

document.addEventListener('click', e => {
const link = e.target.closest('a')
if (!link) return
const isExternal = link.hostname !== window.location.hostname
if (isExternal) {
navigator.sendBeacon('/track', JSON.stringify({
event: 'outbound_link_clicked',
href: link.href,
}))
}
})

sendBeacon is the right API. Regular fetch calls get cancelled when the page unloads, which is the failure mode you don’t want on the most expensive interaction of the session.

Same problem as external links. The user submits, the server responds with a new page, your JS is gone. Fire the conversion event before the form submission, not after.

form.addEventListener('submit', () => {
navigator.sendBeacon('/track', JSON.stringify({
event: 'form_submitted',
form_id: form.id,
}))
// Don't preventDefault - let the submit proceed normally
})

This is the SPA-specific version of flicker and FOOC. The experiment SDK initialised on the first page. By route four, no new SDK call has happened, but the page state has changed enough that the variant logic may not apply correctly.

Two practices help:

  1. Re-evaluate flags on route change. Treat each route as a new opportunity for the SDK to check assignments. Most modern SDKs expose a refresh() or equivalent.
  2. Bind variant decisions to the user, not the page. Once a user is in a variant, they should be in it for the duration of the test, regardless of which route fired the original assignment. The feature flag layer should handle this if the assignment is keyed on user ID rather than session start.

Shopify’s classic checkout, some payment providers, and most embedded forms live in an iframe. The iframe is a separate document with its own JS context. Pageviews and events fired inside the iframe don’t reach the parent’s analytics, and vice versa. The end of your funnel - where the money is - is the part that quietly disappears.

postMessage is the bridge: the iframe posts events out, the parent listens and forwards them to analytics. It works but it’s manual, and on Shopify pre-Plus you can’t always inject into the checkout iframe at all. The honest answer is to do conversion tracking server-side via webhooks (order created, order paid) rather than client-side from inside the iframe.

SPAs centralise everything into one set of JS bundles. When an ad blocker drops a request - the analytics endpoint, the experiment SDK, the data layer - it doesn’t fail one tracking call. It silently disables your entire instrumentation for the rest of the session. Server-side or edge tagging on a first-party domain is the reliable fix. First-party subdomains for the tracking endpoint are the second-best one.

When SPA tracking is broken, the symptoms are weird rather than obvious. Funnel reports show inflated bounce rates because no pageview ever fires after the first one. Time-on-page is inaccurate because the “page” never ended. Variant-specific conversion rates look wrong because the experiment SDK never re-evaluated. The fix isn’t a single change - it’s a list of route-change hooks across the router, the analytics SDK, and the experiment SDK. Walk the list in order, not the symptoms.