SPA Analytics: Tracking Page Views in Single-Page Apps
Traditional web analytics is built around one simple assumption: each page load is a pageview. This breaks in single-page applications.
The SPA problem
In a single-page application (React, Vue, Angular, Svelte), the initial page load fetches the app shell. All subsequent "page" changes update the DOM without triggering a new HTTP request. From the browser's perspective, the user is still on the same page.
A standard analytics snippet fires once on load. Navigate from /home to /pricing to /signup — the snippet fires once, records one pageview, and misses everything after.
This is not a theoretical concern. If you install a naive script tag on a React or Next.js SPA, your analytics will dramatically undercount pageviews and misrepresent which pages users actually visit.
How popstate events work
Browsers expose a popstate event that fires when the URL changes due to browser history navigation (back/forward buttons). The History API also provides pushState and replaceState for programmatic URL changes.
Most SPA routers use pushState to navigate. The problem: pushState does not fire a popstate event.
The standard Antlytics script tag listens to popstate:
window.addEventListener("popstate", send);
This catches browser back/forward navigation. It does not catch pushState navigation — the router-driven transitions that make up most SPA navigation.
The full solution: intercepting pushState
To catch every navigation in a SPA, you need to intercept pushState and replaceState calls:
(function() {
var t = "YOUR_TRACKING_ID",
u = "https://www.antlytics.com/api/ingest/pageview",
k = "ant_sid";
function sid() {
try {
var s = sessionStorage.getItem(k);
if (s) return s;
s = crypto.randomUUID();
sessionStorage.setItem(k, s);
return s;
} catch(e) { return crypto.randomUUID(); }
}
function send() {
fetch(u, {
method: "POST",
headers: { "Content-Type": "application/json" },
keepalive: true,
body: JSON.stringify({
tracking_id: t,
pathname: location.pathname,
referrer: document.referrer || undefined,
session_id: sid()
})
}).catch(function() {});
}
// Wrap pushState and replaceState
var originalPushState = history.pushState;
var originalReplaceState = history.replaceState;
history.pushState = function() {
originalPushState.apply(history, arguments);
send();
};
history.replaceState = function() {
originalReplaceState.apply(history, arguments);
// Do not track replaceState — this is typically used for
// same-page state updates, not real navigations.
};
window.addEventListener("popstate", send);
send(); // Initial pageview
})();
This approach works for any SPA framework without framework-specific integration.
Framework-specific solutions
Next.js (recommended approach)
For Next.js, use the @antlytics/analytics SDK. The <Analytics> component handles App Router and Pages Router navigation automatically, including the pushState interception.
See the Next.js analytics setup guide for full setup instructions including the first-party proxy.
React Router
If you're using React Router (without Next.js), use the pushState intercept script above. Place it in your index.html or public/index.html in the <head>.
Alternatively, add a route change listener using React Router's useLocation hook in your root component:
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
function Analytics({ trackingId }) {
const location = useLocation();
useEffect(() => {
fetch('https://www.antlytics.com/api/ingest/pageview', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
keepalive: true,
body: JSON.stringify({
tracking_id: trackingId,
pathname: location.pathname,
session_id: getSessionId()
})
}).catch(() => {});
}, [location.pathname, trackingId]);
return null;
}
SvelteKit
See analytics for SvelteKit for the afterNavigate approach.
Vue Router / Nuxt
See analytics for Nuxt for the Nuxt plugin approach. For plain Vue Router without Nuxt, use a global afterEach navigation guard:
router.afterEach((to) => {
sendPageview(to.path);
});
Testing SPA analytics
Verify your setup captures all navigations:
- Open your site in the browser with dev tools → Network tab open.
- Navigate through multiple pages using internal links.
- Confirm a POST request to
api/ingest/pageviewfires for each navigation. - Check that the
pathnamein each request matches the URL shown in the address bar.
Common failure modes:
- Multiple pageviews firing for a single navigation (duplicate event listeners)
- No pageviews after the initial load (missing navigation hook)
- Pageviews fire with the previous pathname (timing issue — reading
location.pathnamebefore the URL updates)
FAQ
Can I use the standard script tag and just accept missing navigations? For content sites where most sessions are single-page (someone arrives, reads, leaves), the initial pageview covers most of your meaningful data. For apps where users navigate between many views, missing navigations significantly distorts your data.
Does the SDK handle all of this automatically?
The @antlytics/analytics Next.js SDK handles Next.js routing. For other frameworks, you need either the pushState intercept approach or a framework-specific integration.
Why doesn't the standard script tag just intercept pushState?
The standard script includes popstate for broad compatibility. The pushState intercept requires more careful handling to avoid double-counting in frameworks that mix pushState and popstate events.
What about hash-based routing?
If your SPA uses hash routing (/#/page instead of /page), listen for the hashchange event instead of (or in addition to) popstate.
Related: Next.js analytics setup guide · SvelteKit analytics · Nuxt analytics · Antlytics implementation guides