Skip to content
trakoo
Esc
navigateopen⌘Jpreview
On this page

SvelteKit

Integrate trakoo into a SvelteKit app — client tracking, server events, and user identification.

trakoo runs the same in the browser and on the server, which maps onto SvelteKit’s split between components and +server.ts/load functions/actions. This guide wires up both sides with PostHog as the example provider; the event definitions you write are shared across them.

Install

Install trakoo and your provider SDK

npm install trakoo posthog-js posthog-node

PostHog ships separate browser and Node packages. Other providers differ — see Providers for each one’s requirements.

Set environment variables

SvelteKit only exposes variables prefixed with PUBLIC_ to the browser. Keep server secrets unprefixed.

# Browser (exposed to the client)
PUBLIC_POSTHOG_KEY=your-posthog-api-key
PUBLIC_POSTHOG_HOST=https://app.posthog.com

# Server only (never sent to the browser)
POSTHOG_API_KEY=your-posthog-api-key

Define your events

Describe every event once in a shared runtime registry so the client and server instances stay in sync.

import { defineEvents, typed } from 'trakoo';

export const appEvents = defineEvents({
  pageViewed: {
    name: 'page_viewed',
    category: 'navigation',
    properties: typed<{
      path: string;
      title: string;
    }>()
  },
  buttonClicked: {
    name: 'button_clicked',
    category: 'engagement',
    properties: typed<{
      buttonId: string;
      location: string;
    }>()
  },
  userSignedUp: {
    name: 'user_signed_up',
    category: 'user',
    properties: typed<{
      email: string;
      plan: 'free' | 'pro' | 'enterprise';
    }>()
  },

  apiRequest: {
    name: 'api_request',
    category: 'system',
    properties: typed<{
      path: string;
      method: string;
    }>()
  }
});

typed<T>() keeps this primary setup validator-free. To validate or transform properties at runtime, pass any Standard Schema-compatible validator instead; see Runtime validation.

Client analytics

Create one stateful client instance and import it wherever you track in the browser.

import { createClientAnalytics } from 'trakoo/client';
import { PostHogClientProvider } from 'trakoo/providers/client';
import { PUBLIC_POSTHOG_KEY, PUBLIC_POSTHOG_HOST } from '$env/static/public';
import { appEvents } from './events';

export const analytics = createClientAnalytics({
  events: appEvents,
  providers: [
    new PostHogClientProvider({
      token: PUBLIC_POSTHOG_KEY,
      api_host: PUBLIC_POSTHOG_HOST
    })
  ],
  debug: import.meta.env.DEV
});

Initialize and track page views

Initialize once in the root layout, then track a page view on every client-side navigation with afterNavigate. It also fires on the first load, so the initial view is captured too.

<script lang="ts">
  import { onMount } from 'svelte';
  import { afterNavigate } from '$app/navigation';
  import { page } from '$app/state';
  import { analytics } from '$lib/analytics';

  let { children } = $props();

  onMount(() => {
    analytics.initialize();
  });

  afterNavigate(() => {
    analytics.pageView({
      path: page.url.pathname,
      title: document.title
    });
  });
</script>

{@render children()}

Track events in components

<script lang="ts">
  import { analytics } from '$lib/analytics';

  function handleClick() {
    void analytics
      .track('button_clicked', {
        buttonId: 'signup-cta',
        location: 'hero'
      })
      .catch((error) => {
        console.error('Analytics tracking failed:', error);
      });
  }
</script>

<button on:click={handleClick}>Sign up</button>

Identify users

Client analytics is stateful: call identify() when the user logs in and every later event carries them until you reset(). Driving it from layout data keeps identification in one place.

<script lang="ts">
  import { analytics } from '$lib/analytics';

  let { data, children } = $props();

  $effect(() => {
    if (data.user) {
      analytics.identify(data.user.id, {
        email: data.user.email,
        name: data.user.name,
        plan: data.user.plan
      });
    } else {
      analytics.reset();
    }
  });
</script>

{@render children()}

See Identifying Users for traits and typing.

Server analytics

Server analytics is stateless and edge-ready: pass user context with each call. For the serverless pattern shown here, create one request-owned instance from private env vars.

import { createServerAnalytics } from 'trakoo/server';
import { PostHogServerProvider } from 'trakoo/providers/server';
import { POSTHOG_API_KEY } from '$env/static/private';
import { PUBLIC_POSTHOG_HOST } from '$env/static/public';
import { appEvents } from './events';

export function createRequestAnalytics() {
  return createServerAnalytics({
    events: appEvents,
    providers: [
      new PostHogServerProvider({
        apiKey: POSTHOG_API_KEY,
        host: PUBLIC_POSTHOG_HOST
      })
    ],
    debug: import.meta.env.DEV
  });
}

Load functions and actions

Track wherever the server does meaningful work. Load functions, form actions, and API endpoints all create a local pair, track inside try, and shut down in finally.

import { createRequestAnalytics } from '$lib/server-analytics';
import { fail, redirect } from '@sveltejs/kit';
import type { Actions } from './$types';

export const actions: Actions = {
  signup: async ({ request }) => {
    const data = await request.formData();
    const email = data.get('email') as string;
    const plan = data.get('plan') as 'free' | 'pro' | 'enterprise';

    if (!email) {
      return fail(400, { email, missing: true });
    }

    const user = await db.user.create({ data: { email, plan } });
    const analytics = createRequestAnalytics();

    try {
      await analytics.track('user_signed_up', { email, plan }, {
        userId: user.id,
        user: { email: user.email, traits: { plan } }
      });

      throw redirect(303, '/dashboard');
    } finally {
      await analytics.shutdown();
    }
  }
};

The redirect is thrown from inside try, so finally shuts down the local pair before it propagates. There is no catch block that could swallow the redirect.

An API endpoint looks the same:

import { json } from '@sveltejs/kit';
import { createRequestAnalytics } from '$lib/server-analytics';
import type { RequestHandler } from './$types';

export const POST: RequestHandler = async ({ request }) => {
  const body = await request.json();
  const user = await db.user.create({ data: body });
  const analytics = createRequestAnalytics();

  try {
    await analytics.track('user_signed_up', {
      email: body.email,
      plan: body.plan
    }, {
      userId: user.id,
      user: { email: user.email, traits: { plan: user.plan } }
    });

    return json({ user });
  } finally {
    await analytics.shutdown();
  }
};

Reuse user context

Resolve the current user once and share a small helper across load functions, actions, and hooks.

import type { RequestEvent } from '@sveltejs/kit';

export function getUserContext(event: RequestEvent) {
  const user = event.locals.user;
  if (!user) return undefined;

  return {
    userId: user.id,
    user: {
      email: user.email,
      traits: { name: user.name, plan: user.plan }
    }
  };
}
import { createRequestAnalytics } from '$lib/server-analytics';
import { getUserContext } from '$lib/get-user-context';
import type { PageServerLoad } from './$types';

export const load: PageServerLoad = async (event) => {
  const analytics = createRequestAnalytics();

  try {
    await analytics.track('page_viewed', {
      path: '/profile',
      title: 'Profile'
    }, getUserContext(event));

    return { user: event.locals.user };
  } finally {
    await analytics.shutdown();
  }
};

Track requests in hooks

hooks.server.ts is a good place to populate the user from a session and track server-wide events such as API requests.

import { createRequestAnalytics } from '$lib/server-analytics';
import { getUserContext } from '$lib/get-user-context';
import type { Handle } from '@sveltejs/kit';

export const handle: Handle = async ({ event, resolve }) => {
  const sessionId = event.cookies.get('sessionid');
  if (sessionId) {
    event.locals.user = await db.getUserFromSession(sessionId);
  }

  if (event.url.pathname.startsWith('/api/')) {
    const analytics = createRequestAnalytics();

    try {
      await analytics.track('api_request', {
        path: event.url.pathname,
        method: event.request.method
      }, getUserContext(event));

      return await resolve(event);
    } finally {
      await analytics.shutdown();
    }
  }

  return resolve(event);
};

Enhanced forms

With use:enhance you can track the submission client-side while the action records the authoritative server event. Both call the same event definitions, so send a lightweight signal from the browser and the meaningful one from the server.

<script lang="ts">
  import { enhance } from '$app/forms';
  import { analytics } from '$lib/analytics';

  let { form } = $props();
</script>

<form
  method="POST"
  action="?/signup"
  use:enhance={() => {
    void analytics
      .track('button_clicked', {
        buttonId: 'signup-submit',
        location: 'signup-form'
      })
      .catch((error) => {
        console.error('Analytics tracking failed:', error);
      });

    return async ({ update }) => update();
  }}
>
  <input name="email" type="email" value={form?.email ?? ''} required />
  {#if form?.missing}<p class="error">Email is required</p>{/if}
  <button type="submit">Sign up</button>
</form>

Deployment

Request-owned analytics runs on Node and edge adapters alike. In both cases, shut down the local pair in finally before the request ends.

import { json } from '@sveltejs/kit';
import { createRequestAnalytics } from '$lib/server-analytics';
import type { RequestHandler } from './$types';

export const config = { runtime: 'edge' };

export const GET: RequestHandler = async () => {
  const analytics = createRequestAnalytics();

  try {
    await analytics.track('api_request', {
      path: '/api/edge',
      method: 'GET'
    });

    return json({ ok: true });
  } finally {
    await analytics.shutdown();
  }
};

Troubleshooting

Events aren’t reaching the browser provider. Client variables must be prefixed with PUBLIC_; without it SvelteKit won’t expose them, and PUBLIC_POSTHOG_KEY will be undefined. Server variables stay unprefixed.

$env import errors. Use static imports — PUBLIC_* from $env/static/public, secrets from $env/static/private — rather than $env/dynamic/*, so values are inlined at build time.

Type errors on the analytics functions. Import the client from trakoo/client and the server from trakoo/server. The root trakoo entry exports shared types and environment-neutral event helpers; factories remain on their client and server subpaths.

Next steps

Was this page helpful?