Adding Privacy-First Analytics to a SvelteKit App
SvelteKit 2 intercepts client-side navigation differently from other frameworks. Here is the correct pattern for tracking route changes without cookies or a consent banner.
SvelteKit 2 breaks analytics integrations that hang measurement off the page lifecycle. A plain <script> tag fires once, on the first document load, and never again — so every client-side navigation after it is invisible. The Monoid tracker avoids this by watching the History API instead of the page lifecycle.
Why a plain script tag is not enough
A <script> tag in <svelte:head> executes once per document load. SvelteKit 2's router swaps content in place without unloading the document, so nothing re-runs it. Any tracker whose only trigger is DOMContentLoaded or load reports the entry route and then goes quiet.
This is the root cause of the widely-reported issue where Google Analytics GA4 records only the first pageview on initial load in SvelteKit 2 applications.
Detecting the navigation itself is the answer, and it does not require a SvelteKit-specific integration. SvelteKit's client router updates the URL by calling history.pushState, so a tracker that patches history.pushState observes every client-side route change without subscribing to $app/navigation or any framework hook.
The correct approach
Add the Monoid tracker script once to your root +layout.svelte using <svelte:head>:
<svelte:head>
<script
async
src="https://api.monoid.website/tracker.min.js"
data-site-id="YOUR_SITE_ID"
></script>
</svelte:head>
The tracker's DOMContentLoaded listener covers the initial page load. For subsequent client-side navigations, its history.pushState patch runs immediately after SvelteKit has updated the URL, so it reads the new location.pathname and sends one pageview per route change.
One caveat to know: the patch covers pushState, not popstate. Back and Forward button presses change the route without calling pushState, so they do not currently produce a new pageview.
To verify, open the Network tab in DevTools and watch for POST requests to /collect. You should see one per route change.
Separating staging traffic
SvelteKit projects typically run on localhost during development. Register a separate site and bind its site_id to an environment variable so development traffic never pollutes production metrics:
<script>
const siteId = import.meta.env.VITE_MONOID_SITE_ID
</script>
<svelte:head>
{#if siteId}
<script
async
src="https://api.monoid.website/tracker.min.js"
data-site-id={siteId}
></script>
{/if}
</svelte:head>
Set VITE_MONOID_SITE_ID in .env.local for development (or leave it unset to suppress tracking) and in your deployment environment for production. The {#if siteId} block ensures the tracker is never injected when the variable is not set.
What you do not need
No cookie consent banner. No cookie policy update. No navigator.cookieEnabled check. The collection endpoint derives a daily visitor hash from the IP address, User-Agent, a server-side secret, and the current date:
visitor_hash = SHA-256(IP + UA + SALT_SECRET + YYYY-MM-DD)
The hash resets every 24 hours and cannot be reversed to recover the IP or User-Agent. Nothing is stored on the visitor's device. This means the integration does not trigger ePrivacy or PECR consent requirements — there is nothing to consent to.
Verifying in the dashboard
After deploying, open your analytics dashboard and navigate between several routes on your live site. Each navigation should produce a new pageview entry with the correct path. If you see only one entry regardless of navigation, check that the script is loaded in the root layout and that VITE_MONOID_SITE_ID is set in the production environment.
The SvelteKit adapter you use (Cloudflare, Vercel, Node, static) does not affect analytics collection, since tracking happens entirely in the browser after hydration.
Comments
Loading comments…