# Single-page apps

> How the ClickClacks tracker turns route changes in React, Vue, Svelte, Next.js and Nuxt apps into pageviews, when scroll depth is recorded, and the edge cases: hash routers, query-string updates and virtual pages.

- Canonical URL: https://clickclacks.io/docs/single-page-apps
- Section: Tracking
- Last updated: 2026-09-26

Next.js, Nuxt, React Router, Vue Router and SvelteKit change pages without a reload. The tracker notices on its own, so every route gets its own pageview with no router code.

## How route changes are tracked {#how}

The tracker wraps `history.pushState` and `history.replaceState` and listens for `popstate` (the back and forward buttons). After each one it compares the full URL with the last one it counted, and sends a `$pageview` when it changed. Every modern router uses the History API, so this covers them all.

- Load the script once, in the document head. Adding it from a component that re-renders could load it again.
- Don’t send your own pageview events on route change: you’d count every page twice.
- Only the first pageview of a page load carries `referrer`. Later route changes are part of the same visit.

## Scroll depth per route {#scroll}

Scroll depth is measured per page view. When the route changes, the tracker sends `$scroll` for the page being left, then starts measuring the new one. It also sends it when the tab is hidden or closed.

## Query-string updates {#query}

Any URL change counts, including one made with `replaceState`. An app that writes its filters or search terms into the query string records a pageview each time they change, with the new query string in `path`. If that’s noisier than you want, add `data-query="off"` to the script tag so paths are sent without query strings, or filter those paths out in your reports.

## Hash routers {#hash}

The `#fragment` is never sent, because it can hold anything from a heading anchor to an access token. So an app that routes with the hash (`/#/settings`, `/#/billing`) records every route under the same `path`, often just `/`. Switch the router to history mode (for example `createWebHistory()` in Vue Router or `createBrowserRouter` in React Router), or send a custom event for the screens you care about.

## Steps without a URL {#virtual}

Dialogs, wizards and tabs that don’t change the URL aren’t pageviews. When a step matters, send a [custom event](https://clickclacks.io/docs/events.md) for it:

```ts title="checkout.ts"
// A multi-step dialog that never changes the URL
function goToStep(step: 'plan' | 'details' | 'payment') {
  window.clickclacks?.('event', 'Checkout step viewed', { step })
}
```

## Next steps {#next}

- [Where the tag goes](https://clickclacks.io/docs/install.md#frameworks) in each framework.
- [Autocapture](https://clickclacks.io/docs/autocapture.md): what each pageview, click and scroll carries.
