Single-Page App Analytics Without Google

React, Vue and Svelte apps load once and change the URL in JavaScript. Here is exactly which of those changes a cookieless script counts, what it misses, and how to wire up events without a tag manager.

By Null Agency · Updated October 24, 2026 · Written for front-end developers shipping React, Vue or Svelte apps with a client-side router

TL;DR: a history-mode router needs no glue code, a hash router collapses to one path

A single-page app loads once and then rewrites the URL in JavaScript, so any analytics script that only fires on page load sees one pageview per visit. GhostMetrics handles this inside gm.js by wrapping history.pushState and listening for popstate. Every route change your router makes through the History API becomes a pageview.

The free plan covers one site with unlimited pageviews, so you can test all of this on a real app at no cost: start free, or open the live demo first.

Why a single-page app breaks page-load analytics

A traditional site asks the server for a new HTML document on every click. Each document load runs your analytics script again, which sends a pageview. A single-page app does this once. After the first load, React Router, Vue Router or SvelteKit intercepts link clicks, swaps the rendered components, and updates the address bar with history.pushState. The browser never reloads, so a script that only runs at load time records the landing route and nothing else. A visitor who reads six routes shows up as one pageview.

The classic fix is a hook in the router that calls gtag('event', 'page_view') by hand. It works, and it is code you maintain. The alternative is to let the analytics script observe the browser primitive every history-mode router is built on. That is what gm.js does.

Here is exactly what the script does, read from its source rather than from a feature list. At startup it replaces history.pushState with a wrapper that calls the original and then runs its own navigation handler. It also adds a popstate listener that runs the same handler. The handler does five things in order:

  1. Increments the per-tab pageview counter held in sessionStorage under gm_depth.
  2. Sends the leave beacon for the route you are leaving: time on page in seconds and furthest scroll percentage.
  3. Resets the timer, the scroll maximum and the leave latch for the new route.
  4. Reads the new location.pathname.
  5. Sends a pageview for it, flagged as not an entry.

Nothing in that sequence needs a framework. It works under React, Vue, Svelte, Solid or a router you wrote yourself, provided the navigation goes through pushState.

What counts as a pageview, event by event

The table lists the navigation events a single-page app produces and what GhostMetrics does with each.

What happens in your appPageview?Detail
Router link click or navigate() (pushState)YesCounted with the new path. Never flagged as an entry.
Browser back or forward button (popstate)YesCounted as another pageview, same as a forward navigation.
Redirect or URL sync via history.replaceStateNoThe function is not wrapped, so these calls are invisible.
Change to the URL hash only (#/pricing, or an in-page #anchor link)Yes, under the same pathFragment navigation fires popstate, and hash routers call pushState, so each one is counted. The path is stored without the fragment, so all of them look like one page.
Query-string change pushed with pushState (?page=2)YesCounted, but stored with the same path as the page before it.
Full page reloadYesThe tab counter increments, so a reload is not an entry.
Landing on any URL in a fresh tabYes, as an entryThe first pageview in a tab is the entry, whichever route it is.

Three rows deserve a closer look because they are where counts drift from what you expect.

replaceState is invisible

Routers use replaceState for redirects, for canonicalizing a URL, and for keeping a search box or tab selection in the address bar without adding history entries. None of those calls reach GhostMetrics. That is usually what you want. If your router implements a real navigation with replaceState, though, it produces no pageview, and you will see fewer views than route changes.

Query-only pushes inflate one path

The script stores location.pathname and nothing after it. A product list that pushes ?sort=price, then ?sort=price&page=2, produces two extra pageviews on the same path. If your filters write to the URL with pushState, expect those pages to look busier than the number of distinct screens would suggest. Switching those updates to replaceState is the clean fix if you do not need them in browser history.

Back and forward are pageviews

A visitor who clicks into a route, goes back, and clicks into it again generates three pageviews. The counter treats a history traversal like any other navigation. It inflates pages per visit on an app where people bounce between a list and a detail view.

Add the tag to a React, Vue or Svelte app

The script tag goes in the one static HTML file your app ships. In a Vite project that is index.html at the project root, for React and Vue alike. Create React App and Vue CLI projects keep it at public/index.html. SvelteKit uses src/app.html. Other frameworks follow the same pattern with their own shell file.

  1. Get your site ID. Sign up for the free plan and copy the snippet from the dashboard. The site ID is the data-site value.
  2. Paste the tag into the head, above your bundle. Deferred scripts run in document order, so listing it first means gm.js installs its pushState wrapper before the router starts.
  3. Build and read the output. Open the built index.html and confirm the tag survived with its data-site attribute. If a build step rewrites or strips it, switch to the verbatim or inline option for that tool.
  4. Click through three routes in a fresh tab, then watch the real-time view. You should see the landing route as an entry and two more pageviews.
  5. Press back once. That adds a pageview too. If your counts disagree with the table above, jump to the limits section.
<!-- index.html (Vite: React, Vue, Svelte without Kit) -->
<head>
  <meta charset="UTF-8" />
  <title>My app</title>
  <script defer src="https://ghostmetrics.nullagency.io/gm.js" data-site="YOUR-SITE-ID"></script>
</head>
<!-- src/app.html (SvelteKit) -->
<head>
  <meta charset="utf-8" />
  <script defer src="https://ghostmetrics.nullagency.io/gm.js" data-site="YOUR-SITE-ID"></script>
  %sveltekit.head%
</head>

Load it once, from the static file. It is tempting to inject the tag from a component with useEffect or onMounted. Do not. The script has no guard against running twice, and each run wraps pushState again and adds another popstate listener. A component that remounts, or a hot-reload cycle in development, then records every navigation more than once. A static tag runs exactly once per full page load.

If your app is server-rendered, the same tag goes in the root layout. Our Next.js guide covers the App Router and Pages Router placements. For a site with no framework at all, see adding analytics to a static site.

Custom events from components that render late

Pageviews tell you which routes get read. Events tell you what people do on them. GhostMetrics gives you two ways to record one, and both behave well in a framework that mounts and unmounts elements constantly.

Declarative. Add data-gm-event="signup-click" to any element. The script registers one delegated click listener on the document, in the capture phase, so an element your router renders after load is tracked with nothing to re-bind. Capture phase also means a component that calls stopPropagation() in its own handler does not hide the click from GhostMetrics. The listener walks up from the clicked node, so a click on an icon inside a button still counts for the button. One side effect: if you nest two elements that both carry the attribute, a click on the inner one records both events.

Programmatic. Call window.ghostmetrics.track('name', { section: 'pricing' }) from a handler. Only the section key is stored. Every event carries the path at the moment of the click and any UTM held for the session. There is no numeric value field and no revenue field.

Use optional chaining. The window.ghostmetrics object is created at the very end of the script. If the visitor sends Global Privacy Control or Do Not Track, gm.js returns before reaching that line and collects nothing, so window.ghostmetrics does not exist for them. An ad blocker that stops the script file has the same effect. A bare window.ghostmetrics.track() inside a click handler then throws a TypeError for exactly the privacy-conscious visitors your page is written for. window.ghostmetrics?.track() costs nothing and avoids it.

// React
function PricingCta() {
  return (
    <a href="/signup" data-gm-event="pricing-cta">Start free</a>
  );
}

function ExportButton({ onExport }) {
  const handle = () => {
    window.ghostmetrics?.track('export-click', { section: 'reports' });
    onExport();
  };
  return <button onClick={handle}>Export</button>;
}
<!-- Vue 3 single-file component -->
<script setup>
function onSave() {
  window.ghostmetrics?.track('settings-save', { section: 'settings' })
}
</script>

<template>
  <button data-gm-event="trial-start">Start trial</button>
  <button @click="onSave">Save</button>
</template>
<!-- Svelte -->
<script>
  function copyInstall() {
    window.ghostmetrics?.track('copy-install', { section: 'docs' });
  }
</script>

<button data-gm-event="newsletter-signup">Subscribe</button>
<button on:click={copyInstall}>Copy install command</button>

Event names are free text, so settle on a convention early, such as lowercase and hyphenated. The dashboard's Custom Events panel lists each name with its count and a Last Triggered time. For per-campaign or per-route analysis, use the raw export, which is available on every plan. The full pattern is in custom events without Google Analytics.

Reading the numbers when the whole app is one page load

Entry pages and visitors

The first pageview in a tab is the entry, and the entry flag is the only thing the Visitors card counts. Route changes inside the app are never entries. If someone lands directly on /pricing from a search result, /pricing is the entry, not your home route. The Entry Pages panel will show the routes people land on, not every route.

Bounce rate

A bounce is a tab session with exactly one pageview. In a single-page app, the first route change after landing adds a second pageview, so the landing page stops being a bounce at that moment. This is the classic definition, not GA4's engaged-session rule. An app whose login does not change the URL reports high bounce, because nothing is counted after the landing.

Time on page

Time on page is measured per route view, from the moment the route appeared to the moment the visitor navigated away or first hid the tab. The hidden-tab case is a latch: after the first hide, that route view is done. A visitor who switches to another tab for ten minutes has the time frozen at the switch. The Avg Time column averages only the rows where a leave beacon arrived.

Scroll depth

Scroll depth is the furthest point reached, as a percentage of scrollable height, tracked from scroll events on the window. It resets to zero on each route change. Two consequences for SPAs. If your router restores the previous scroll position and the visitor does not scroll again, the route reports 0 even though they are part-way down. And if your app scrolls inside a fixed-height container rather than the window, the script reads the window, not your container, so that route reports 0 or no scroll data.

Referrers and UTMs repeat on every route

The referrer comes from document.referrer, which belongs to the document and does not change when you push a new URL. Every route in the visit therefore carries the landing referrer. The Referrers and UTM Breakdown panels count pageview rows, so one visitor from a newsletter who reads five routes adds five to that campaign's count. The UTM itself is read from the URL when present and otherwise from sessionStorage, so a router that strips the query string after landing does not lose the attribution. Read those panels as pageviews by source, not visits by source. The UTM parameters guide covers naming.

Exit pages

The exit flag is set on every route view that sends a leave beacon, and a single-page app sends one on each route change. In an SPA nearly every route view carries the flag, so the Exit Pages panel ranks close to raw views. Compare exits against views and entries before concluding a route loses people.

Core Web Vitals

The script observes FCP, LCP, CLS and TTFB through PerformanceObserver and Navigation Timing, then sends one report per full page load, as soon as FCP, LCP and TTFB have all been seen or after ten seconds. That report describes the initial load of your app shell, but it is filed under the path that is current when it is sent, so if the visitor changes route before it goes out, the first-load numbers land on the second route. It does not describe soft navigations, which is where a lot of SPA slowness lives, and INP is not captured. Treat the Performance panel as a measure of first-load cost. See Core Web Vitals from real users.

Hash routing and the other limits

Hash routing collapses to one path. Older router configurations, some static hosting setups, and apps built with createHashRouter or a hash-mode Vue Router keep the route after a #: https://example.com/#/pricing. To the browser that is one document with a changing fragment. gm.js has no hashchange listener and stores the path without the fragment. Navigations still arrive: browsers fire popstate on fragment navigation, and current hash-mode routers in React Router and Vue Router change the hash through pushState, which the script wraps. So you get a pageview per hash route, but every one is labelled with the same path, and the dashboard cannot tell your routes apart. The same applies to ordinary in-page #anchor links on any page: each click adds a pageview on the same path and ends the previous view's time on page.

The options, in order of preference: switch the router to history mode and configure the host to serve index.html for unknown paths; keep hash routing and record the views you care about with ghostmetrics.track('view-pricing') from a route change hook, then read them in the Custom Events panel; or accept per-visit totals and give up route-level data on that app.

Other limits worth knowing before you ship.

The migration path from GA4 is in migrating from Google Analytics.

SPA analytics options compared

Every tool here has an answer for single-page apps. The rows are qualitative; vendors change plans and settings, so confirm details on their own pages.

ToolCookielessHow route changes are recordedCustom eventsHosting
GhostMetricsYesAutomatic via pushState and popstate; hash routes all stored as one path; replaceState not countedYes (data-gm-event, track())Hosted only; free for one site
GA4No (cookies by default)Enhanced measurement can detect history changes; many teams still send page_view manually; check current docsYes, extensiveGoogle-hosted; free tier
PlausibleYesDocuments SPA support; check its docs for hash-based routingYes (goals and custom events)Hosted, or self-host the Community Edition
FathomYesDocuments SPA support; check current docsYesHosted only; paid
UmamiYesDocuments SPA support through its trackerYesSelf-host or Umami Cloud
Cloudflare Web AnalyticsYesBeacon with an SPA option; check current docsNoHosted; free

GA4 wins when you need Google Ads attribution, audiences, or ecommerce funnels, and it deserves its place there. For everything an app team usually asks of analytics, meaning which routes get read, where people arrive, which buttons get clicked and whether first load is fast, a cookieless script with no consent banner covers it. Plausible and Umami are the right pick if you need open source or the option to self-host. Our Google Analytics alternatives comparison goes through the trade-offs in more depth.

SPA pageviews with one script tag and no router glue

CookielesspushState + popstate$0 forever, 1 site$9.99/mo flat ProHash routes = one path

GhostMetrics is cookieless web analytics from Null Agency, and we run it on this site. The script is about 3 KB compressed on the wire. Drop it into the head of your index.html and route changes made through the History API are counted as pageviews, with time on page and scroll depth recorded per route. data-gm-event works on elements your framework renders late. No cookies, no local storage, no IP addresses stored.

The free plan is free forever: one website, unlimited pageviews, a real-time dashboard, every view, full history. Pro is $9.99/mo flat, adds unlimited websites (fair-use ceiling of 250) and a public shareable dashboard, and starts with a 30-day free trial (card required, nothing charged for 30 days, cancel anytime). Raw event export as CSV or JSON is on every plan, including free.

Start free, no card See the live demo

FAQ

Does GhostMetrics count React Router, Vue Router and SvelteKit navigation?
Yes, when the router changes the URL with history.pushState, which is what the history-mode routers in all three do. GhostMetrics also counts back and forward button presses (popstate). It does not count history.replaceState calls, and it does not distinguish routes that differ only in the URL hash.
Do I need to call a function on every route change?
No. The script wraps history.pushState and listens for popstate, so a router that uses the History API needs no glue code and no router hooks. Put the one script tag in your index.html and each route change is recorded as a pageview.
Does GhostMetrics work with hash routing like /#/about?
Not in a useful way. Hash navigations are still counted as pageviews, but gm.js has no hashchange listener and it records only the URL path, never the fragment, so every hash route reports the same path. Switch the router to history mode, or fire your own events with ghostmetrics.track for the views you care about.
Why are my pageview counts higher than I expected in a single-page app?
Each pushState call is one pageview. A router that calls pushState twice for one navigation, a filter or tab that pushes a query string, a redirect done with pushState, the back button, in-page #anchor links, or the script loading twice will all add pageviews. Check which of those your app does.
Does GhostMetrics measure Core Web Vitals on every route change?
No. It sends one vitals report per full page load, covering FCP, LCP, CLS and TTFB for the initial load. Soft navigations between routes are not measured, and INP is not captured at all.

Keep reading

Next.js Analytics Without Google

App Router and Pages Router placements

Custom Events Without GA

data-gm-event and track(), with naming rules

Analytics for Podcasts and Creator Sites

Episode pages, show notes and creator-site traffic

GhostMetrics Live Demo

The real dashboard, live data

Disclosure: GhostMetrics is built by Null Agency and we use it on this site, so we are not a neutral party about our own product. The behavior described here was read from the script's source code; where a competitor's setup or pricing matters, check the vendor's current documentation before deciding.