# 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](/docs/tracker/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:

1. The engagement time and scroll depth of the page you're leaving are
   sent (with the `engage` module).
2. 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](/docs/tracker/manual-mode) with `data-spa="off"`. See
[SPA double counting](/docs/troubleshooting/spa-double-counting).
