# Single-page apps

A single-page app uses the same tag as any other site, with nothing more to set up.

The tag counts each page your app moves to as a pageview of its own, whichever router it uses, so its pages rank and filter like any other site’s. If your paths hold account names or record numbers, one attribute keeps them out of your stats.

## What counts as a pageview

- The page the browser loads.
- Each move to a new address with `history.pushState`, which is how the routers of React, Vue, Svelte, Angular and the rest change pages.
- Each press of the back or forward button that changes the address.
- A page the back or forward button restores from the browser’s back/forward cache without loading it.

A move to the address already counted isn’t counted again, and nor is a change after the `#` alone. `history.replaceState` doesn’t count either, since it only renames the page the visitor is already on.

Pages are counted by path, so a move that changes only the query string, such as to `?page=2`, is another view of the same page.

A page the browser prerenders in case the visitor goes to it next is counted when they do, and not at all if they don’t.

## Private paths

If your paths hold an account’s name or a record’s number, such as `/acme/invoices/4815`, count them without it using [`data-path`](https://fiveb.ar/docs/script-attributes.md#data-path). One ending in `/*` fits every route that starts the same way, and follows the app as it moves: `data-path="/:account/*"` counts `/acme/invoices` as `/:account/invoices`.

For routes of other shapes, update the attribute before the app changes the address, with `tag.setAttribute('data-path', '/:account/invoices/:id')`: the script reads it for every page. The back and forward buttons are counted as the address changes, before your router runs, so a single `/*` pattern that fits every route is the most reliable.

## Apps that route after `#`

An app whose addresses differ only after the `#`, such as `/#/about`, is counted as one page, `/`. To count its pages separately, switch the router to real paths, which most routers call history mode.

## Sources

A page the app moves to is reached from the site itself, like the second page of any other site, so it has no [source](https://fiveb.ar/docs/sources-and-campaigns.md) of its own, not even Direct. The visit keeps the source it arrived with, so for traffic from a source, [read visits rather than pageviews](https://fiveb.ar/docs/sources-and-campaigns.md#read-visits-not-pageviews).

Before 24 September 2026, each page an app moved to took the referrer of the page it landed on, so pageviews from a source include an app’s later pages before then.

## Speed and engagement

Each page the app moves to gets its own [time on page and scroll depth](https://fiveb.ar/docs/engagement.md#single-page-apps), sent when the app moves on. [Page speed](https://fiveb.ar/docs/speed.md#what-is-measured) is measured once, for the page the browser loaded, since a move within the app loads nothing to time.

## Site search

A search counts with the pageview it was made on, so a search box that rewrites the address with `history.replaceState` as the visitor types counts none, and one that pushes a new address as they type counts a search for each. Where the address doesn’t hold the search, set `data-search` on the tag before the app moves to the results. See [Site search](https://fiveb.ar/docs/site-search.md#single-page-apps).

## Error pages

Only a page the browser loaded has an HTTP status, so a not found page the app draws itself is counted as an ordinary page. See [Error pages](https://fiveb.ar/docs/error-pages.md#single-page-apps).

## Custom properties

The script reads its tag once, as it loads, so every page the app moves to carries the [custom properties](https://fiveb.ar/docs/properties.md#read-once-when-the-script-loads) of the page it loaded on.
