Track single-page apps (SPAs)
How the tracker follows single-page app navigation with the History API or hash routing, what counts as a new page, and how to avoid counting pageviews twice.
Most single-page apps (React Router, Next.js, Vue Router, SvelteKit,
Angular, Astro view transitions…) change pages with history.pushState.
The tracker handles them with no configuration.
Modes (data-spa)#
| Value | Behavior |
|---|---|
auto (default) |
Wraps history.pushState and listens to popstate (back/forward). Each navigation to a new path sends a pageview |
history |
Same as auto |
hash |
Listens to hashchange and keeps the #fragment in the path (/#/settings). For routers that use #/ URLs |
off |
Only the initial page load is tracked. Use the JavaScript API for the rest |
With auto, fragment changes aren't pageviews, and history.replaceState
isn't tracked (routers use it for things like updating query strings).
What counts as a new page#
A pageview is sent when the path changes: the pathname, plus the
fragment in hash mode. Changing only the query string (?tab=2) doesn't
send one, and neither does navigating to the same path again. Call
privatus.pageview() to force one.
For each SPA navigation:
- The engagement time and scroll depth of the page you're leaving are
sent (with the
engagemodule). - The referrer of the new pageview is a URL on your own site, so internal navigation never shows up as a new traffic source. The visit keeps the source it started with.
Double counting#
If you also call privatus.pageview() in your router's navigation hook,
or fire the tracker again from a tag manager on history changes, each
navigation is counted twice. Pick one: the built-in SPA tracking, or
manual mode with data-spa="off". See
SPA double counting.