Analytics tools track page views in a single-page app by wrapping history.pushState(), often history.replaceState() too, and listening for the popstate event, so every route change your router makes is reported like a page load. Plausible, Umami, Vercel Web Analytics and invisit do this out of the box. GA4 needs enhanced measurement’s browser history setting, PostHog needs defaults of '2025-05-24' or later, and Fathom needs data-spa="auto".
The numbers go wrong at the edges: router code that sends its own page view on top of the tool’s, redirects and search boxes that rewrite the URL with replaceState, hash routes, the back/forward cache, prerendered pages, and routers built on the newer Navigation API. On 3 November 2026 we ran each of those cases through seven tools in Chrome and counted the page view requests each one sent. invisit, the cookieless analytics tool we build, is one of the seven, weak spots included.
How do analytics tools detect route changes in a single-page app?
A client-side router changes the address with the History API and renders the next page itself, so the browser never loads a new document and a plain analytics snippet never runs again. pushState and replaceState fire no event, so trackers replace them with wrappers that call the original and then compare the address with the last one they counted. Paste this into a browser console to see the pattern:
// Roughly what every tracker in this guide installs.
const sendPageView = (url, title) => console.log('page view', url, title)
let last = location.pathname + location.search
function check() {
const current = location.pathname + location.search
if (current === last) return // each tool has its own rule for "same page"
last = current
sendPageView(location.href, document.title)
}
for (const method of ['pushState', 'replaceState']) {
const original = history[method]
history[method] = function (...args) {
const result = original.apply(this, args)
check()
return result
}
}
addEventListener('popstate', check) // back and forwardThree more browser events matter: hashchange fires when only the #fragment changes, pageshow with event.persisted set to true fires when the back/forward cache restores a page, and prerenderingchange fires when a prerendered page is opened.
Tools differ in three choices: which of these hooks they install, what counts as a new page (the path, the path and query, or the full URL with the fragment), and when they read the title.
If your tool records only the landing page, check its switch first (GA4’s browser history setting, PostHog’s defaults, Fathom’s data-spa), then whether your router uses the Navigation API.
Which analytics tools track SPA route changes, and how?
Each cell is the number of page view requests a tool sent for one action, as of 3 November 2026. A 2 means one page was counted twice.
| What happened | GA4 | Plausible | Umami | Fathom | PostHog | Vercel | invisit |
|---|---|---|---|---|---|---|---|
Link to a new path (pushState) |
1 | 1 | 1 | 1 | 1 | 1 | 1 |
Query-only change with pushState |
1 | 0 | 1 | 1 | 0 | 0 | 1 |
Query-only change with replaceState |
1 | 0 | 1 | 0 | 0 | 0 | 0 |
Guard sends /account to /login with replaceState |
1 (/login) |
1 (/account) |
2 (/login twice) |
1 (/account) |
2 | 1 (/account) |
2 |
Anchor link to #section on the same page |
0 | 0 | 0 | 1 | 0 | 0 | 0 |
Hash router link (pushState to #/about) |
0 | 0 | 1 | 1 (as /) |
0 | 0 | 0 |
| Back or forward button | 1 | 1 | 0 | 1 | 1 | 1 | 1 |
| Restored from the back/forward cache | 0 | 1 | 0 | 0 | 0 | 0 | 1 |
| Prerendered page | At opening1 | At opening | While prerendering | At opening1 | At opening | While prerendering | At opening |
| Navigation API router, link click | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| Router code also sends a page view | 2 | 2 | 2 | 2 | 2 | 2 | 1 |
| Title set 100 ms after the URL changes | New title | No titles kept | New title | No titles kept | Old title | No titles kept | New title |
How we tested: one page per tool in Chrome 155, driven by Playwright, with a simulated router calling the History API. Every vendor request was intercepted in the browser and answered locally, so nothing reached the vendors. The scripts were those served that day: gtag.js for a stream with the browser history setting on, Plausible’s script.js (tracker version 37), Umami Cloud’s script, Fathom with data-spa="auto", posthog-js 1.445.1 with defaults: '2026-08-30', Vercel’s script 0.1.3 and invisit’s tracker 0.1.0. Without data-spa, Fathom recorded only the first page load.
Why does GA4 double count page views in a React app?
GA4 double counts when enhanced measurement sends a page view for each history change and your router code sends another. Every tool in our test except invisit counted the page twice when both ran. An effect like this one also counts the landing page twice, because it runs on mount as well:
import { useEffect } from 'react'
import { useLocation } from 'react-router'
declare const gtag: (...args: unknown[]) => void
// Doubles every page view while GA4's history setting is on.
export function PageViews() {
const location = useLocation()
useEffect(() => {
gtag('event', 'page_view', { page_location: window.location.href })
}, [location])
return null
}Google’s docs are explicit: send_page_view: false only stops the page view sent when the tag loads, and GA4 keeps sending history page views until you turn off “Page changes based on browser history events” for the web stream (Measure pageviews). Google also warns against that setting when your tags are configured in Google Tag Manager, because it can double count (Measure single-page applications).
Pick one sender. Delete your own call and let the tool’s hooks work, or switch the hooks off and keep your call:
| Tool | Switch automatic page views off | Then send one yourself |
|---|---|---|
| GA4 | send_page_view: false and the history setting off |
gtag('event', 'page_view') |
| Plausible | autoCapturePageviews: false in init() |
plausible('pageview') |
| Umami | data-auto-pageview="false" (Umami 3.2.0 and later) |
umami.track() |
| Fathom | data-auto="false" and no data-spa |
fathom.trackPageview() |
| PostHog | capture_pageview: false |
posthog.capture('$pageview') |
| Vercel | Pass route and path to the React <Analytics /> component |
Sent when those props change |
| invisit | Not possible | pageview(), which skips a repeat URL |
Sources: Plausible, Umami, Fathom, PostHog, and the @vercel/analytics 2.0.1 source, whose Next.js component does this with usePathname().
The script tag loaded twice has the same effect. Plausible’s SPA docs warn that Next.js and Gatsby can load async scripts twice. When we added each tag a second time, Plausible and Umami counted every page twice and Fathom in SPA mode four times. Vercel and invisit ignored the second copy.
Why is the first page view counted twice or not at all?
The landing page is counted twice when a route effect like the one above fires on mount on top of the tool’s own first page view. Skip the effect’s first run, or switch the automatic first page view off as well.
It goes missing in the opposite setup: automatic page views off, and your own code subscribed only to changes, such as a popstate handler or a router listener that does not fire for the initial location. Send one page view at startup, then one per change.
Three tools delay the first page view on purpose. Plausible and PostHog wait until the tab is visible, so a page opened in a background tab counts when it is first shown. Umami’s script starts once the document has finished loading (tracker source), so a visitor who leaves before that is not counted.
Why does analytics show the wrong page title after a route change?
The title is wrong when the tool reads document.title as soon as the URL changes and your router sets the new title afterwards, in an effect or once the next page’s data has loaded. This reproduces it in any tool:
history.pushState({}, '', '/pricing')
setTimeout(() => {
document.title = 'Pricing'
}, 100)In our test PostHog recorded the previous page’s title, and GA4, Umami and invisit recorded the new one. Plausible, Fathom and Vercel keep no titles. Umami waits 300 ms after a URL change before sending, so a title set later than that is still missed. PostHog waits only until the code that called pushState has finished, which catches a title set in that same code but missed ours. GA4 sent its history page views a few seconds later in batches, and they carried the new title. invisit holds a client-side page view until the title changes, for up to two seconds, and sends it at once when another event, another navigation or leaving the page comes first, so events never arrive ahead of their page.
Fix it by setting the title before the URL changes, or by switching automatic page views off and sending them after the page renders. Counts stay right either way; only the title stored with each page view is wrong.
Do redirects and query-string changes count as new page views?
They do in some tools, and two patterns produce most of the noise.
A route guard that sends a signed-out visitor from /account to /login with replaceState right after the push leaves one page on screen, /login. GA4 recorded only /login. Plausible, Fathom and Vercel ignore replaceState, so they recorded /account, which the visitor never saw, and missed /login. Umami recorded /login twice, because both of its delayed sends read the final URL. PostHog and invisit recorded both addresses. Guards that run before the router commits the URL, such as Vue Router’s beforeEach or a React Router loader that returns redirect(), push only the final address and avoid this.
A search box that writes ?q= with replaceState as someone types is counted on every write by GA4, which listens for pushState, popstate and replaceState (enhanced measurement events). Typing two letters added two page views in our test. Umami does the same unless you add data-exclude-search="true", which also drops the query string from every URL Umami records. Plausible, Fathom, PostHog, Vercel and invisit ignore query-only replaceState. If GA4 is your tool, write the query to the URL only when the search is submitted.
Query changes made with pushState, such as tabs or pagination, count in GA4, Umami, Fathom and invisit. PostHog counts them only with capture_pageview: { path: true, search: true }, added in posthog-js 1.418.13 (release notes).
How do I track page views with a hash router?
Most tools treat #/about as the same page as /, and the hash options in Plausible and Fathom listen only for hashchange, which a hash router’s link clicks never fire. React Router’s HashRouter and Vue Router’s createWebHashHistory() both navigate with pushState to a #/path URL (checked in react-router 8.5.1 and vue-router 5.4.0), and pushState fires no hashchange.
| Tool | Hash routes, tested |
|---|---|
| GA4 | Dropped: page_location excludes the fragment by default. Send gtag('event', 'page_view', { page_location: location.href }) per route. |
| Plausible | hashBasedRouting: true counted location.hash changes and back or forward, not link clicks |
| Umami | Counted pushState hash routes with titles, not location.hash changes or back and forward |
| Fathom | data-spa="hash" behaved like Plausible’s option; data-spa="auto" counted every change as / |
| PostHog | Counted all three with capture_pageview: { path: true, hash: true } and disable_capture_url_hashes: false |
| Vercel | Not counted as separate pages |
| invisit | Not counted as separate pages; pageview() ignores the fragment too |
The PostHog line needs both options because defaults of '2026-06-25' or later turn disable_capture_url_hashes on, which strips fragments from captured URLs and cancels hash: true. For GA4, the manual hit carried the fragment when we checked the request; we could not confirm how GA4’s reports display it.
The fix that works in every tool is history mode, createBrowserRouter() or createWebHistory(), with your server answering every route with the app’s HTML.
Are back/forward cache restores counted as page views?
When a visitor leaves for another document and presses back, the browser can restore your page from its back/forward cache without running its scripts again. web.dev notes that many analytics libraries do not count these restores, so totals drop as more pages qualify. In our test only Plausible and invisit counted a restore.
To count restores in the other tools, listen for pageshow yourself:
addEventListener('pageshow', (event) => {
// persisted is true only for a restore from the back/forward cache.
if (event.persisted) gtag('event', 'page_view') // or umami.track(), fathom.trackPageview() ...
})Counting restores is a choice rather than a bug fix: the visitor did see the page again, but no request reached your server.
Are prerendered pages counted before anyone opens them?
They are in Umami and Vercel. With Speculation Rules, Chrome can load a page in a hidden tab before the visitor clicks and run its scripts. Chrome’s docs say Google Analytics waits for activation by default. In our test invisit, Plausible and PostHog sent nothing until the page was opened, while Umami and Vercel sent the page view during prerendering and nothing when it was opened, so a page that was prerendered but never visited still counted.
Chrome disables prerendering while Playwright or another DevTools client is attached (PrerenderingDisabledByDevTools), so these runs used plain headless Chrome sending to a local server that logged each request.
For Umami and Vercel, load the script only once the page is shown. With Umami this sent nothing during prerendering and one page view 3 ms after opening.
function loadAnalytics() {
const script = document.createElement('script')
script.defer = true
script.src = 'https://cloud.umami.is/script.js'
script.dataset.websiteId = '<website id>'
document.head.append(script)
}
if (document.prerendering) {
document.addEventListener('prerenderingchange', loadAnalytics, { once: true })
} else {
loadAnalytics()
}Does SPA analytics work with the Navigation API?
Not automatically, in any of the seven tools. The Navigation API, Baseline since 13 January 2026 (Chrome and Edge 102, Firefox 147, Safari 26.2), lets a router take over a navigation with event.intercept(). The browser then updates the URL itself, without pushState, so in our test no tool counted a link click handled this way. Back and forward still fire popstate, which six tools counted; Umami, with no popstate listener, recorded nothing after the landing page.
Send the page view from the intercept handler once the new page has rendered, and skip back and forward, which the tools already see:
navigation.addEventListener('navigate', (event) => {
if (!event.canIntercept || event.hashChange || event.downloadRequest) return
event.intercept({
async handler() {
await render(new URL(event.destination.url)) // your router's rendering
if (event.navigationType !== 'traverse') plausible('pageview')
},
})
})With Plausible and PostHog this gave exactly one page view per navigation, back included. With invisit, call pageview() for every navigation type: it skips the address it counted last, so back and forward are not doubled.
Why does Fathom show every SPA page as the homepage?
Fathom records the URL in <link rel="canonical"> when a page has one (Fathom’s script settings). A single-page app that ships one static canonical link in its HTML shell reports every route as that address: in our test, all 11 page views landed on the canonical path. Update the canonical link on each route, or add data-canonical="false" to the script tag.
How invisit tracks single-page apps
invisit’s tracker wraps pushState and replaceState, listens for popstate and pageshow, and follows these rules:
pushStatecounts when the address without its fragment changes, so a new query string counts.replaceStatecounts only when the path changes.- The fragment is never part of the address, so anchor links and hash routes stay one page. invisit cannot count hash routes separately.
- A back/forward cache restore counts as a page view. A prerendered page counts when it is opened, not before.
- A page view waits up to two seconds for the router to change the title, and goes out at once when another event, another navigation or leaving the page comes first.
- A second copy of the script, or a second
init(), reuses the running tracker.pageview()does nothing when the address is the one counted last. - Before each new page view, time on page and heatmap clicks so far are sent for the previous route.
- Automatic page views cannot be switched off.
Next.js details are in our Next.js guide, Astro’s view transitions in our Astro guide, and setup in installation.
A five-minute test for your SPA page views
Open DevTools, filter the Network panel by your tool’s endpoint (collect for GA4, api/event for Plausible, api/send for Umami, v1/events for invisit), and walk through your app:
- Load any page. Expect exactly one request.
- Click three internal links. Expect one each, with the new URL and title.
- Change a filter or type in a search box. Decide whether that is a page view, and configure the tool to match.
- Click an anchor link inside a page. Expect nothing.
- Press back, then forward. Expect one each.
- Open a guarded page while signed out. Expect one request, for the page you land on.
- Follow a link to another site, then press back. Decide whether the restore should count.
- Search your code for page view calls (
page_view,pageview,$pageview,trackPageview). With automatic page views on, the only ones left should be the bfcache and Navigation API cases above.
The trap to avoid is the second sender: one stray page view call in a route effect doubles every number in your dashboard, and nothing in the dashboard tells you.
Footnotes
-
Not run, because these endpoints could not be pointed at a local server. GA4’s cell comes from Chrome’s prerendering docs, Fathom’s from its script, which waits for
prerenderingchange. ↩ ↩2
