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 file so the client and server instances stay in sync.

import type { CreateEventDefinition, EventCollection } from 'trakoo';

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

  apiRequest: {
    name: 'api_request',
    category: 'system',
    properties: {} as {
      path: string;
      method: string;
    }
  }
} as const satisfies EventCollection<Record<string, CreateEventDefinition<string>>>;

export type AppEvents = typeof appEvents;

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 type { AppEvents } from './events';

export const analytics = createClientAnalytics<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() {
    analytics.track('button_clicked', {
      buttonId: 'signup-cta',
      location: 'hero'
    });
  }
</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, and flush before the request finishes. Create one 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 type { AppEvents } from './events';

export const serverAnalytics = createServerAnalytics<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 follow the same shape: track(event, properties, context), then shutdown().

import { serverAnalytics } 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 } });

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

    await serverAnalytics.shutdown();

    throw redirect(303, '/dashboard');
  }
};

An API endpoint looks the same:

import { json } from '@sveltejs/kit';
import { serverAnalytics } 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 });

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

  await serverAnalytics.shutdown();

  return json({ user });
};

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 { serverAnalytics } from '$lib/server-analytics';
import { getUserContext } from '$lib/get-user-context';
import type { PageServerLoad } from './$types';

export const load: PageServerLoad = async (event) => {
  await serverAnalytics.track('page_viewed', {
    path: '/profile',
    title: 'Profile'
  }, getUserContext(event));

  await serverAnalytics.shutdown();

  return { user: event.locals.user };
};

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 { serverAnalytics } 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/')) {
    await serverAnalytics.track('api_request', {
      path: event.url.pathname,
      method: event.request.method
    }, getUserContext(event));

    await serverAnalytics.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={() => {
    analytics.track('button_clicked', {
      buttonId: 'signup-submit',
      location: 'signup-form'
    });

    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

The server instance runs on Node and edge adapters alike, and the rule is the same on both: flush before the response returns.

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

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

export const GET: RequestHandler = async () => {
  await serverAnalytics.track('api_request', {
    path: '/api/edge',
    method: 'GET'
  });

  await serverAnalytics.shutdown();

  return json({ ok: true });
};

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 types only, not the createClientAnalytics/createServerAnalytics factories.

Next steps

Was this page helpful?