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-nodePostHog 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-keyDefine 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.
