Back to blog

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 broke most existing analytics integrations silently. A plain <script> tag that worked fine in SvelteKit 1 will only fire on hard reloads in SvelteKit 2, missing every client-side navigation. The standard fix — hooking history.pushState — does not work either, because SvelteKit intercepts navigation at the router level before pushState is called. The Monoid tracker handles this correctly out of the box.

Why the usual approach fails

Traditional analytics scripts detect route changes by monkey-patching history.pushState and history.replaceState. SvelteKit's router calls its own internal navigation primitives and only calls pushState as a side effect, after the route transition is already in progress. By the time the patched pushState fires, the new route may not yet be fully rendered, and in some navigation types (programmatic goto() calls, <a> clicks with preloading) the timing produces duplicates or missed events.

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.

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 built-in DOMContentLoaded listener fires on the initial page load. For subsequent client-side navigations, the tracker's history.pushState hook fires after SvelteKit's internal navigation has resolved — meaning every route, including the first, is tracked exactly once with no duplicates or missed events.

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.