Skip to content
ExponaBLOG
SVELTE & SVELTEKIT · DAY 074 / 100

Analytics in SvelteKit for Svelte & SvelteKit developers

Measure without slowing pages.

Expona Admin7 min read

Analytics in SvelteKit for Svelte & SvelteKit developers

Most teams treat analytics as an afterthought, bolting it on after launch and accepting the performance hit as unavoidable. In reality, we can measure what matters without degrading user experience or compromising privacy. This guide walks through building analytics infrastructure that respects both your visitors and your Core Web Vitals.

Why analytics matters at scale

When you're running production SvelteKit applications serving real users, you need to understand what they're doing. Are users bouncing on signup? Where do they spend time? Which features drive retention? Without data, you're making decisions blind. But poorly implemented analytics can turn a 2-second page load into a 5-second one, or leak sensitive user data to third-party vendors.

Analytics in SvelteKit presents a specific challenge: our universal rendering model means code runs on both server and client. We need to instrument navigation without double-counting, respect the streaming response model, and avoid blocking the critical path.

Privacy-friendly analytics

Before shipping any analytics solution, ask whether you own your data. Third-party services like Google Analytics or Mixpanel ingest your events into their infrastructure, apply retention policies you don't control, and sell insights (or at minimum, use your data to train models). For many teams, this isn't acceptable.

Privacy-friendly alternatives like Plausible, Fathom, or self-hosted Umami give you three advantages: compliance with GDPR and similar regulations out of the box, no cookies required (so no banner), and event data that stays on your infrastructure. If you're handling sensitive data (health, finance, identity), self-hosting is often mandatory.

For a bootstrapped SaaS or internal tool, self-hosted Umami is hard to beat. It's lightweight, stores events in PostgreSQL, and provides a clean dashboard. Here's how we'd integrate it into a SvelteKit app:

typescript
// src/lib/analytics.ts
export function trackEvent(eventName: string, properties?: Record<string, any>) {
  if (typeof window === 'undefined') return;
  
  const payload = {
    website_id: import.meta.env.VITE_ANALYTICS_WEBSITE_ID,
    name: eventName,
    url: window.location.pathname,
    properties: properties || {},
    timestamp: Date.now()
  };

  navigator.sendBeacon(
    import.meta.env.VITE_ANALYTICS_ENDPOINT,
    JSON.stringify(payload)
  );
}

navigator.sendBeacon is critical here: it sends HTTP POST requests that survive page unloads and don't block navigation. The browser handles retries and queuing. This won't delay your users leaving the page.

Track page views on navigation

In a traditional server-rendered site, every page load triggers a new request and analytics happen server-side automatically. SvelteKit's client-side navigation means we must explicitly track route changes. Miss this, and your dashboard will show zero views for half your app.

The right place to instrument this is a layout component that wraps every page. In SvelteKit, create a top-level +layout.svelte:

typescript
// src/routes/+layout.svelte
<script>
  import { page } from '$app/stores';
  import { trackPageView } from '$lib/analytics';
  import { onMount } from 'svelte';

  let previousPath = '';

  onMount(() => {
    // Track initial page on mount (client-side navigation)
    previousPath = $page.url.pathname;
    trackPageView($page.url.pathname);
  });

  $: {
    // Track when route changes
    if ($page.url.pathname !== previousPath) {
      previousPath = $page.url.pathname;
      trackPageView($page.url.pathname);
    }
  }
</script>

<slot />

The reactive block $: { ... } runs whenever $page.url.pathname changes. This fires for every client-side navigation. Since this layout wraps all pages, it's guaranteed to run.

For server-side rendered pages (hard refresh or direct links), you'll want server-side instrumentation too:

typescript
// src/routes/+layout.server.ts
import { trackPageViewServer } from '$lib/analytics.server';

export async function load({ url, request }) {
  // Record server-side render
  await trackPageViewServer(url.pathname, {
    userAgent: request.headers.get('user-agent'),
    referer: request.headers.get('referer')
  });

  return {};
}

This double-counts client-side navigations (once client-side, once if you're doing server-side tracking), but only for CSR pages. If your analytics ingest is idempotent (same user, same timestamp, same path = single event), it's a non-issue. Or keep server-side tracking minimal and rely on client-side.

Defer third-party scripts

Even if you're using a privacy-friendly service, third-party JavaScript is third-party JavaScript. It runs in your main thread and blocks rendering if not careful.

Never load analytics in the critical path. Place it in +page.svelte or inject it after hydration completes. SvelteKit's onMount hook is perfect for this:

typescript
<script>
  import { onMount } from 'svelte';

  onMount(() => {
    // Load analytics script after page is interactive
    const script = document.createElement('script');
    script.async = true;
    script.defer = true;
    script.src = 'https://umami.example.com/script.js';
    script.setAttribute('data-website-id', import.meta.env.VITE_ANALYTICS_ID);
    document.head.appendChild(script);
  });
</script>

Better yet, avoid third-party scripts entirely. Implement your analytics as a module you control:

typescript
// src/lib/analytics.ts
class AnalyticsClient {
  private endpoint: string;
  private websiteId: string;
  private queue: any[] = [];
  private isReady = false;

  constructor(endpoint: string, websiteId: string) {
    this.endpoint = endpoint;
    this.websiteId = websiteId;
  }

  pageView(pathname: string) {
    this.queue.push({
      type: 'pageview',
      pathname,
      timestamp: Date.now()
    });
    this.flush();
  }

  event(name: string, properties?: Record<string, any>) {
    this.queue.push({
      type: 'event',
      name,
      properties,
      timestamp: Date.now()
    });
    this.flush();
  }

  private flush() {
    if (this.queue.length === 0) return;

    const batch = this.queue.splice(0, 10);
    navigator.sendBeacon(
      `${this.endpoint}/api/events`,
      JSON.stringify({
        website_id: this.websiteId,
        events: batch
      })
    );
  }
}

export const analytics = new AnalyticsClient(
  import.meta.env.VITE_ANALYTICS_ENDPOINT,
  import.meta.env.VITE_ANALYTICS_WEBSITE_ID
);

This is pure JavaScript with no external dependencies. Your bundle gains a few KB but you control performance entirely. Batching events (flushing 10 at a time) reduces overhead further.

GDPR and similar regulations require explicit consent before tracking. Plausible and Fathom are exempt because they use no cookies and process minimal data, but self-hosted analytics and Google Analytics require a consent banner.

Implement a simple consent store:

typescript
// src/lib/consent.ts
import { writable } from 'svelte/store';

export const consentGiven = writable<boolean>(
  typeof window !== 'undefined'
    ? localStorage.getItem('consent') === 'true'
    : false
);

export function setConsent(value: boolean) {
  localStorage.setItem('consent', String(value));
  consentGiven.set(value);
}

Then gate your analytics:

typescript
// src/lib/analytics.ts
import { get } from 'svelte/store';
import { consentGiven } from './consent';

export function trackEvent(eventName: string, properties?: Record<string, any>) {
  if (!get(consentGiven)) return; // Don't track
  if (typeof window === 'undefined') return;

  navigator.sendBeacon(
    import.meta.env.VITE_ANALYTICS_ENDPOINT,
    JSON.stringify({
      website_id: import.meta.env.VITE_ANALYTICS_WEBSITE_ID,
      name: eventName,
      timestamp: Date.now()
    })
  );
}

Show a banner on first visit, store the response, and only track if the user opts in. This isn't just legal compliance—it builds trust.

Common mistakes

Double-counting events: Tracking both server-side and client-side without deduplication means every page view is logged twice. Solution: Pick one approach (prefer client-side for SvelteKit) or include unique identifiers so your backend can deduplicate.

Synchronous tracking blocks navigation: Using fetch without keepalive: true or navigator.sendBeacon can delay page unloads. Always use sendBeacon for critical events, or fire and forget with a timeout fallback.

Logging PII: Never log usernames, emails, or IDs in event properties unless your analytics backend is fully encrypted and your infrastructure is locked down. A breach exposes sensitive data. Use anonymous user IDs instead.

Ignoring bundle size: Adding Google Analytics, Segment, and Mixpanel together can add 40+ KB of JavaScript. Audit what you're actually using. Often, a single self-hosted solution is faster and cheaper.

Forgetting to strip analytics in staging: Analytics configured in your build can send production data from staging environments. Use environment variables to gate analytics by deployment environment.

Key takeaways

  • Choose privacy-first analytics (Plausible, Fathom, self-hosted Umami) to avoid GDPR friction and vendor lock-in.
  • Track page views in a top-level layout component using Svelte's reactive statements to catch client-side navigation.
  • Use navigator.sendBeacon for analytics to avoid blocking page unloads or navigation.
  • Defer third-party scripts and prefer implementing analytics as a module you control.
  • Gate all tracking behind a consent check; respect user choice and store it in localStorage.
  • Avoid double-counting by picking either server-side or client-side tracking, not both.
  • Never log personally identifiable information in event properties.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles