How to track single-page app pageviews (React, Vue, Next) without cookies
If your React, Vue, or Next.js app shows traffic to one page and almost nothing else, your analytics isn't broken — it's doing exactly what a classic script does on a single-page app: counting the first page load and missing every route change after it. In a SPA, clicking a link doesn't reload the page; the router swaps the view with the History API (pushState), and a tracker that only fires on page load never hears about it.
The fix depends on your tool. Some auto-track History-API navigations with no extra code; some need a setting flipped or a special script; one makes you call a tracking function by hand on every route change. The short version: Plausible, Simple Analytics, and Simplytics track SPA route changes automatically with no config; GA4 and Fathom need a setting; Matomo needs manual code on each navigation. With Simplytics specifically, the standard ~1.9 KB script does it out of the box — every route change becomes a pageview, the previous page is sent as the referrer, and there are no cookies and no consent banner involved.
Why SPAs break the default pageview count
A traditional multi-page site fires a fresh document load on every navigation, so any analytics script in the page runs once per page — naturally counting each view. A single-page app loads the document once. After that, the framework's router (React Router, Vue Router, Next's router, SvelteKit, Angular Router) intercepts link clicks and calls history.pushState() / history.replaceState() to change the URL and render new content — with no document reload. Back/forward fire a popstate event instead.
So the question every SPA-aware tracker has to answer is: does it hook into the History API to notice those virtual navigations? If it doesn't, you get one pageview per session and a wildly understated page count.
How each analytics tool handles SPA route changes
| Tool | SPA route tracking | What you have to do |
|---|---|---|
| Simplytics | Automatic — zero config | Nothing. The standard script wraps pushState/replaceState and listens for popstate. |
| Plausible | Automatic — zero config | Nothing for History-API routers; hash routing needs the script.hash.js variant. |
| Simple Analytics | Automatic — zero config | Nothing for History-API routers; hash routing is the documented edge case. |
| Google Analytics 4 | Automatic with a setting | Enable "Page changes based on browser history events" in Enhanced Measurement — and don't also enable it if you tag via Google Tag Manager, or you double-count. |
| Fathom | Automatic with an attribute | Add data-spa="auto" (or "history" / "hash") to the script tag. Off until you do. |
| Matomo | Manual (or a Tag Manager trigger) | Call setCustomUrl(), setReferrerUrl(), setDocumentTitle() and trackPageView() on each route change — or wire a History-Change trigger in Matomo Tag Manager. |
Three honest notes on that table, because the differences are smaller than any one vendor's marketing implies:
- Plausible and Simple Analytics are in the same zero-config tier as Simplytics for History-API routers. None of them is uniquely magic here; they all hook the same browser APIs. (Where Simplytics differs is the price and the cookie-less, EU-data model — more below.)
- GA4 can do it, but its own SPA guidance is a list of caveats: enable the right Enhanced Measurement option, or send
page_viewevents manually, and never do both. The GTM double-counting trap catches a lot of people. - Matomo is the most setup — by design, it's a do-it-yourself toolkit. Its official SPA guide gives two routes: wire a History-Change trigger in Matomo Tag Manager, or — without the tag manager — call the tracker by hand on each route change, in the right order, resetting custom variables as you go. Powerful and fully under your control, but it's setup you own either way.
How Simplytics tracks a SPA (and what it deliberately ignores)
When the Simplytics script loads, it records the first pageview, then patches history.pushState and history.replaceState and adds a popstate listener. On each navigation it compares the new hostname + pathname to the current one:
- Path changed → it tracks a new pageview, and sets the referrer to the page you just left. Because that referrer is on your own domain, the server folds it into Direct — exactly as a real in-site navigation would behave on a multi-page site, so your referrer report isn't polluted by internal hops. UTM parameters are re-read from the new URL, so a virtual navigation to
/signup?utm_source=launchis attributed correctly. - Only the query string changed (e.g.
/search?q=...→/search?q=other, or a?tab=2toggle) → no new pageview. Filter and tab state that lives in the query string doesn't inflate your numbers. - Hash-only change (
/#section) → ignored, and hash-based routers (HashRouter, URLs likeexample.com/#/dashboard) are not auto-tracked. This is the one SPA pattern Simplytics doesn't follow automatically — the same edge case Plausible solves with a separate script.
That behavior is verified in the script's test suite: a pushState to a new path re-tracks with the previous page as referrer; a query-only replaceState does not; back/forward (popstate) re-tracks the restored path.
Which frameworks "just work"
Anything that routes through the History API is auto-tracked with no setup: React Router (BrowserRouter), Next.js, Vue Router (HTML5 history mode, createWebHistory), SvelteKit, Angular Router (default PathLocationStrategy), and Nuxt. The exception is the legacy hash mode — HashRouter, Vue Router's createWebHashHistory, Angular's HashLocationStrategy — which is the pattern no zero-config tracker follows.
The catch, stated plainly
Automatic History-API tracking is the right default, but it isn't magic, and two limits are worth knowing before you switch:
- Hash routers need attention. If your app uses
#/routeURLs, Simplytics won't auto-count them. Most modern SPAs use real paths, but if yours doesn't, this matters. - Query-only changes don't create pageviews. Usually what you want (you don't want every filter click logged as a view), but if you genuinely treat
?step=2as a distinct screen, the History-API path change is what you'd want to drive instead.
Neither of these is unique to Simplytics — they're the standard trade-offs of hooking the History API, and the same caveats apply to Plausible's and Simple Analytics' default scripts.
Setup: one tag, nothing else
<script async src="https://simplytics.dev/track.js" data-key="YOUR_KEY"></script>
Drop that in your app's <head> (or your root layout / index.html) once. There's no SPA flag to set, no per-route code, no manual page_view calls. Route changes start counting immediately. The script is ~1.9 KB gzipped — for context on how that compares to the alternatives, see how much JavaScript each analytics tool ships, where GA4's tag weighs in around 140 KB.
Where Simplytics lands
On the SPA question itself, Simplytics is in the top tier but not alone: Plausible and Simple Analytics auto-track History-API routes too, and credit where it's due. The difference is everything around it. Simplytics tracks your SPA automatically and sets no cookies (so no consent banner), stores data in the EU (Warsaw), deletes raw visit rows nightly while keeping aggregate history forever, and costs $1/month — versus Plausible's $9/mo, Fathom's $15/mo, or GA4's cookie-and-consent model. It's the cheapest privacy-friendly Google Analytics alternative that auto-tracks single-page apps, with 50,000 pageviews/month across up to 12 websites and a 30-day free trial, no card required.
That's the recurring theme: a lot of functionality for a lot less money. If you're moving off GA4 for a SPA, the full trade-offs are in Simplytics vs Google Analytics, and the privacy-first comparisons are vs Plausible, vs Fathom, vs Simple Analytics, and vs Matomo.
Competitor SPA-tracking behavior and pricing reflect each vendor's published docs as of June 28, 2026. If a vendor changes its approach, email us and we'll update this post.