Hunor.digital
← All writing
GA4 · Tracking

GA4 on single-page apps: why your pageviews double (or vanish)

SPAs don't reload pages, so page tracking either fires twice or not at all, depending on which two defaults you left on. The one-source-of-truth rule that makes SPA tracking boring.

Lázár HunorDigital Fixer
The short answer

Single-page apps break GA4 page tracking because route changes are not real page loads. The classic failure: enhanced measurement's history-change detection and a manually implemented page_view both fire, doubling counts, or neither is configured and route changes vanish. Pick exactly one page_view source, verify the page title updates before the event fires, and test by counting requests per navigation.

A single-page app is a website that lies to your analytics. The browser loads one real page, and every "navigation" after that is JavaScript swapping content and rewriting the URL. No reload, no fresh page request, none of the events that page tracking was invented around. GA4 has answers for this, and the answers are fine. What is not fine is that there are two of them, they are both on by default in many setups, and running both is how single-page apps end up reporting close to double their real pageviews.

Why do SPA pageviews double?

Because two systems answer the same question. GA4's enhanced measurement includes history-change detection: when the app rewrites the URL through the History API, GA4 fires a page_view on its own. Meanwhile, the developers (or the framework's analytics snippet, or a GTM trigger) also send a page_view per route change, because that is what every integration tutorial says to do.

Both mechanisms are correct alone. Together they double every route-change pageview while, cruelly, counting the initial page load once, so the inflation is not even a clean multiple you could spot from orbit. Sessions look fine, pageviews look healthy, pages-per-session looks great. Everyone is pleased with numbers that are substantially air, in proportion to how much in-app navigation your users do.

The mirror-image failure is quieter: enhanced measurement's history detection toggled off, no manual events wired, and route changes simply do not exist. Your app appears to have a one-page session depth and a bounce problem it does not have.

The rule: one source of truth for page_view

Decide who sends page_view, and make it exactly one system:

There is no option C where both run and someone deduplicates later. Nobody deduplicates later.

The two gotchas inside option B

Timing on the title. Route changes update the URL immediately and the document title a beat later, so a page_view fired on the route event can carry the previous page's title. Every report grouped by page title is then subtly shuffled. Fire after the new view has rendered, or set page_title explicitly from the router's data rather than reading the document.

Attribution that never updates. The page_referrer and campaign context of an SPA session comes from the initial load. If your page_view events rebuild their context per route naively, you can end up with self-referrals or, worse, a mid-session UTM re-read that splits one visit into two campaigns. Send the routing event with the new page_location and let GA4's session logic do its job; do not get creative with referrer fields.

How do you test it?

The test is beautifully dumb. Open the app with the network tab filtered to collect, then navigate: home, product, back, forward, five routes total. Count the page_view hits. The correct answer is exactly one per navigation plus one for the initial load. Two per navigation: you have both sources on, go disable one. Zero: nothing is watching the router.

Then check titles: click through three routes and read the dt parameter on each hit. If it trails one page behind, you have the title race. Ship the fix, retest, and add this five-minute walk to your post-deploy checklist, because the next router upgrade can resurrect any of it. The full production-verification habit this belongs to is in how to verify GA4 tracking.

Framework notes, kept honest

Next.js, Nuxt, SvelteKit and friends each have a blessed integration pattern that changes more often than articles about it. So one durable rule instead of version-specific snippets: find where your framework exposes a route-change lifecycle event, make that the single place page_view is sent, and re-run the dumb test above after every major framework upgrade. The pattern survives; the API names do not.

SPA tracking is not hard. It is unforgiving of ambiguity: every question with two answers becomes a counting bug. One page_view source, explicit titles, a five-minute test after deploys. That is the whole discipline, and it is a small member of the larger contract-and-verification worldview in the tracking implementation guide.