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.
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.
navigate() calls (pushState), and the browser back and forward buttons (popstate).history.replaceState calls. Not recorded: the URL hash. Hash navigations still arrive as pageviews (through popstate or pushState), but every one is stored under the same path. There is no hashchange listener.<script defer> tag in index.html. No package, no router plugin, no hook in your components.data-gm-event on any element, including ones your framework renders later, or window.ghostmetrics?.track() from code. Use the optional chaining.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.
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:
sessionStorage under gm_depth.location.pathname.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.
The table lists the navigation events a single-page app produces and what GhostMetrics does with each.
| What happens in your app | Pageview? | Detail |
|---|---|---|
Router link click or navigate() (pushState) | Yes | Counted with the new path. Never flagged as an entry. |
| Browser back or forward button (popstate) | Yes | Counted as another pageview, same as a forward navigation. |
Redirect or URL sync via history.replaceState | No | The 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 path | Fragment 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) | Yes | Counted, but stored with the same path as the page before it. |
| Full page reload | Yes | The tab counter increments, so a reload is not an entry. |
| Landing on any URL in a fresh tab | Yes, as an entry | The 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.
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.
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.
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.
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.
data-site value.gm.js installs its pushState wrapper before the router starts.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.<!-- 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.
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.
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.
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 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 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.
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.
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.
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 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.
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.
| Tool | Cookieless | How route changes are recorded | Custom events | Hosting |
|---|---|---|---|---|
| GhostMetrics | Yes | Automatic via pushState and popstate; hash routes all stored as one path; replaceState not counted | Yes (data-gm-event, track()) | Hosted only; free for one site |
| GA4 | No (cookies by default) | Enhanced measurement can detect history changes; many teams still send page_view manually; check current docs | Yes, extensive | Google-hosted; free tier |
| Plausible | Yes | Documents SPA support; check its docs for hash-based routing | Yes (goals and custom events) | Hosted, or self-host the Community Edition |
| Fathom | Yes | Documents SPA support; check current docs | Yes | Hosted only; paid |
| Umami | Yes | Documents SPA support through its tracker | Yes | Self-host or Umami Cloud |
| Cloudflare Web Analytics | Yes | Beacon with an SPA option; check current docs | No | Hosted; 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.
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 demoDisclosure: 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.