How to add product analytics to a SvelteKit app

Install the Amplitude Unified SDK in a SvelteKit app through the client init hook, capture page views on every client-side navigation, identify signed-in users from your layout data, and track one custom event. About 10 minutes, all copy-paste.

  • SvelteKit 2.10+
  • Svelte 5
  • Unified SDK 1.x
  • 10 min

Chris Tam · Last updated 13 October 2026

SvelteKit is the application framework for Svelte, rendering each page on the server and then navigating in the browser. Product analytics shows you what people do once the page is interactive: which routes they visit, what they use, and where they drop off.

In this tutorial, you'll add Amplitude to a SvelteKit app through a client-only hook, capture page views, identify signed-in users, and track your first custom event.

What you'll build

A SvelteKit app that initializes Amplitude only in the browser, sends [Amplitude] Page Viewed on the first load and on every client-side navigation, attaches the signed-in user's ID and plan to their events, clears identity on sign-out, and tracks a Project Created event with a template property. By the end you'll see live events in Amplitude's event stream.

Prerequisites

  • A SvelteKit 2.10 or later app with Svelte 5 and TypeScript, for example one created with npx sv create. The init export in src/hooks.client.ts arrived in SvelteKit 2.10.
  • An auth setup that puts the signed-in user on event.locals.user in src/hooks.server.ts, with an id and a plan. Most SvelteKit auth libraries do this already. Step 4 shows the matching type in src/app.d.ts.
  • An Amplitude project API key. In Amplitude, go to Settings > Projects > your project > General.

No Amplitude account yet? A free account covers the tracking on this page. Sign up for free to get your project API key. Your session replay capture quota depends on your plan; compare plans.

1. Install the SDK and add the key

The Quickstart recommends @amplitude/unified for browser apps installed from npm. It bundles analytics, session replay, Feature Experiment, and guides and surveys behind one init call.

Terminal
npm install @amplitude/unified

SvelteKit only exposes env vars prefixed with PUBLIC_ to browser code. The project API key is public and its scope is limited to the minimum needed to ingest data, so shipping it to the browser is expected, but keep it out of source anyway.

.env
PUBLIC_AMPLITUDE_API_KEY=your-project-api-key

2. Initialize in the client hook

src/hooks.client.ts only ever runs in the browser, and the init function it exports runs once when the app starts, before the server-rendered page hydrates. That's exactly where initAll belongs.

src/hooks.client.ts
import type { ClientInit } from '@sveltejs/kit';
import { dev } from '$app/environment';
import { PUBLIC_AMPLITUDE_API_KEY } from '$env/static/public';
import * as amplitude from '@amplitude/unified';

export const init: ClientInit = () => {
  if (!PUBLIC_AMPLITUDE_API_KEY) {
    console.warn('PUBLIC_AMPLITUDE_API_KEY is not set; analytics is off.');
    return;
  }

  // initAll returns a promise; nothing on this page needs to wait for it.
  void amplitude.initAll(PUBLIC_AMPLITUDE_API_KEY, {
    analytics: {
      autocapture: {
        attribution: true,
        pageViews: true,
        sessions: true,
        formInteractions: true,
        fileDownloads: true,
        elementInteractions: true,
      },
    },
    sessionReplay: {
      // Every session while you build; about 1% in production.
      sampleRate: dev ? 1 : 0.01,
    },
  });
};

Why. SvelteKit renders every page on the server first, where there's no window and no browser storage for the SDK to use. Code at the top of +layout.svelte runs during that server render on every request, and a +layout.ts or +page.ts load function runs on the server too. onMount in the root layout runs only in the browser, but after the first paint. The client hook starts the SDK once, in the browser, before the page hydrates. Clicks (elementInteractions) default to off in autocapture, which is why the config names it explicitly.

@amplitude/unified turns on session replay when sessionReplay.sampleRate is above 0. This config records every session in development and about 1% in production, which is where Amplitude recommends starting; raise it over a few days. Set it to 0 to turn session replay off. For the full option list, see the Browser Unified SDK reference.

3. Let autocapture handle navigation

There's no code in this step. After the first server-rendered page, SvelteKit's client router handles navigation in the browser. With pageViews: true, the SDK sends [Amplitude] Page Viewed on init and again on every URL change, which covers <a href> links and goto(). Each event carries the page path, title, and URL.

Don't add an afterNavigate callback that calls track('Page Viewed'). It produces a second, differently named page view on every navigation.

Shallow routing changes the URL too. If you open a modal with pushState from $app/navigation and the URL changes, the SDK records it as a page view, which is usually what you want for a modal someone can link to.

4. Identify the signed-in user

Until you set a user ID, events belong to an anonymous device ID. Set it as soon as the page knows who's signed in, and reset only when someone signs out, so the next person on the same browser isn't merged into the last one.

Declare the user your auth puts on locals, so TypeScript and svelte-check accept locals.user. If your auth library already declares it, keep its version:

src/app.d.ts
declare global {
  namespace App {
    interface Locals {
      // Set by your auth in src/hooks.server.ts.
      user: { id: string; plan?: string } | null;
    }
  }
}

export {};

Return the user from the root layout's server load, so every page gets it as layout data:

src/routes/+layout.server.ts
import type { LayoutServerLoad } from './$types';

export const load: LayoutServerLoad = ({ locals }) => {
  return {
    user: locals.user ? { id: locals.user.id, plan: locals.user.plan } : null,
  };
};

Every page now carries the signed-in user in its data, so don't let a shared cache store signed-in pages.

Then identify from an effect in the root layout. Add the script block to your existing +layout.svelte:

src/routes/+layout.svelte
<script lang="ts">
  import * as amplitude from '@amplitude/unified';

  let { data, children } = $props();
  // Keep your existing imports and markup, such as the favicon <svelte:head>.

  // Layout data reloads after every form action. Only act when the
  // identity actually changes.
  let lastIdentity: string | undefined;

  $effect(() => {
    const id = data.user?.id;
    const plan = data.user?.plan ?? 'unknown';
    const identity = id ? `user-${id}|${plan}` : 'anonymous';
    if (identity === lastIdentity) return;
    lastIdentity = identity;

    // Signed out or not signed in yet: keep the same device ID.
    // Reset only from your sign-out handler.
    if (!id) return;

    // Amplitude drops IDs shorter than 5 characters. Use the same
    // string your server sends as user_id.
    amplitude.setUserId(`user-${id}`);

    const identify = new amplitude.Identify();
    identify.set('plan', plan);
    amplitude.identify(identify);
  });
</script>

{@render children()}

Reset from your sign-out form, not from the layout. If sign-out is a form action, reset in the use:enhance callback once the action succeeds:

src/lib/components/SignOutButton.svelte
<script lang="ts">
  import { enhance } from '$app/forms';
  import * as amplitude from '@amplitude/unified';
</script>

<form
  method="POST"
  action="/logout"
  use:enhance={() => {
    return async ({ result, update }) => {
      if (result.type === 'success' || result.type === 'redirect') {
        // Clears the user ID and issues a new device ID for the next person.
        amplitude.reset();
      }
      await update();
    };
  }}
>
  <button>Sign out</button>
</form>

Point action at your own sign-out action. Render <SignOutButton /> wherever your signed-in navigation lives, for example in src/routes/+layout.svelte.

Why. Svelte runs $effect only in the browser, after the component mounts, so the effect never touches the SDK during the server render and needs no window check. Importing @amplitude/unified in a component is safe on the server; the package doesn't touch browser APIs until initAll runs, so you don't need browser checks or dynamic imports around it. The effect reruns whenever layout data reloads, which includes sign-in, sign-out, and every other form action. The lastIdentity check skips the reruns where the user and plan haven't changed, so you don't send an extra Identify event after each form action. A null user also describes every anonymous visitor on every page load, and reset() issues a new device ID each time it runs, so resetting there splits one visitor into many and breaks anything bucketed by device ID, such as feature flags and Web Experiment. The plan value updates the profile right away, but only shows in charts from the user's next event.

5. Track one custom event

Autocapture covers page views, sessions, forms, and clicks. Track the events that mean something to your product yourself, from a handler that only runs in the browser.

src/routes/projects/new/+page.svelte
<script lang="ts">
  import * as amplitude from '@amplitude/unified';

  let template = $state('blank');

  async function handleCreate() {
    // ... your create logic

    amplitude.track('Project Created', { template });
  }
</script>

<select bind:value={template}>
  <option value="blank">Blank</option>
  <option value="kanban">Kanban</option>
</select>
<button onclick={handleCreate}>Create project</button>

Event handlers only run after a click in the browser, so this is safe on a server-rendered page. Don't call track at the top of the <script> block or in a load function, which also run on the server. For events your SvelteKit server should send, such as a payment confirmed in a form action or a +server.ts endpoint, use the Node.js SDK on the server; the Node.js product analytics page covers identity and flushing there.

MCP. Working in Cursor or Claude Code? Amplitude's MCP server lets your agent read your project's existing event taxonomy before it writes track calls, so new events match names you already use instead of inventing projectCreated next to Project Created. Set up the MCP server.

Verify it worked

Run npm run dev, click through two or three pages, create a project, then check in this order:

  1. Network tab. Filter for api2.amplitude.com. You should see a POST to /2/httpapi returning 200 within about a second of each navigation. Amplitude requests start only after the page loads in the browser; the server render sends none.
  2. Ingestion Debugger. In Amplitude, click Data, select Sources, then open the Ingestion Debugger tab. The successful requests chart should show your uploads. It shows counts, not individual events.
  3. User lookup. Go to Users > User Profiles and search your user- prefixed test ID. The event stream shows [Amplitude] Page Viewed for each page and Project Created with its template property within ten seconds to a minute, and the profile shows plan. No auth yet? Search the device ID instead (the Instrumentation Explorer Chrome extension shows it as device_id); plan appears once a user signs in.

To watch payloads in the console, add logLevel: amplitude.Types.LogLevel.Debug to the analytics block in development.

Troubleshooting

The build fails with "PUBLIC_AMPLITUDE_API_KEY is not exported"

$env/static/public reads variables at build time, and the variable isn't set where the build runs. Add it to .env locally and to your CI or host's build environment. The name must start with PUBLIC_.

No requests in the Network tab

The key is empty, so the warning from step 2 is in the console. The dev server only reads .env at startup, so restart it after editing. Because the key is baked in at build time, changing it in production means rebuilding.

"window is not defined" during server rendering

Something calls the SDK on the server: an initAll or track at the top of a component's <script>, or a call inside a load function. Keep initAll in src/hooks.client.ts, identity in the $effect, and track in event handlers.

Two page view events per navigation

An afterNavigate callback or a page view helper is running alongside autocapture. Delete it.

Signed-in events still show as anonymous

The layout never sees the user. Check that src/routes/+layout.server.ts returns user and that your sign-in action redirects or calls update() in use:enhance, so SvelteKit reloads the layout data and the effect reruns.

Events send but never appear in the project

EU projects need serverZone: 'EU' at the top level of the initAll options and an API key from the EU project.

Autocapture does something different from the code

Remote configuration is on by default, so autocapture settings saved in Amplitude under Data > Settings > Autocapture take precedence over the autocapture block in src/hooks.client.ts. To keep autocapture under code control, add remoteConfig: { fetchRemoteConfig: false } to the analytics block.

Full code reference

Every file from this tutorial in one block, the version to hand to an agent. 7 files.

7 files, 119 lines
// -- .env --
PUBLIC_AMPLITUDE_API_KEY=your-project-api-key

// -- src/hooks.client.ts --
import type { ClientInit } from '@sveltejs/kit';
import { dev } from '$app/environment';
import { PUBLIC_AMPLITUDE_API_KEY } from '$env/static/public';
import * as amplitude from '@amplitude/unified';

export const init: ClientInit = () => {
  if (!PUBLIC_AMPLITUDE_API_KEY) {
    console.warn('PUBLIC_AMPLITUDE_API_KEY is not set; analytics is off.');
    return;
  }

  void amplitude.initAll(PUBLIC_AMPLITUDE_API_KEY, {
    analytics: {
      autocapture: {
        attribution: true,
        pageViews: true,
        sessions: true,
        formInteractions: true,
        fileDownloads: true,
        elementInteractions: true,
      },
    },
    sessionReplay: {
      sampleRate: dev ? 1 : 0.01,
    },
  });
};

// -- src/app.d.ts --
declare global {
  namespace App {
    interface Locals {
      // Set by your auth in src/hooks.server.ts.
      user: { id: string; plan?: string } | null;
    }
  }
}

export {};

// -- src/routes/+layout.server.ts --
import type { LayoutServerLoad } from './$types';

export const load: LayoutServerLoad = ({ locals }) => {
  return {
    user: locals.user ? { id: locals.user.id, plan: locals.user.plan } : null,
  };
};

// -- src/routes/+layout.svelte --
<script lang="ts">
  import * as amplitude from '@amplitude/unified';

  let { data, children } = $props();
  // Keep your existing imports and markup, such as the favicon <svelte:head>.

  // Layout data reloads after every form action.
  let lastIdentity: string | undefined;

  $effect(() => {
    const id = data.user?.id;
    const plan = data.user?.plan ?? 'unknown';
    const identity = id ? `user-${id}|${plan}` : 'anonymous';
    if (identity === lastIdentity) return;
    lastIdentity = identity;
    if (!id) return;
    // Amplitude drops IDs shorter than 5 characters.
    amplitude.setUserId(`user-${id}`);
    const identify = new amplitude.Identify();
    identify.set('plan', plan);
    amplitude.identify(identify);
  });
</script>

{@render children()}

// -- src/lib/components/SignOutButton.svelte --
<script lang="ts">
  import { enhance } from '$app/forms';
  import * as amplitude from '@amplitude/unified';
</script>

<form
  method="POST"
  action="/logout"
  use:enhance={() => {
    return async ({ result, update }) => {
      if (result.type === 'success' || result.type === 'redirect') {
        amplitude.reset();
      }
      await update();
    };
  }}
>
  <button>Sign out</button>
</form>

// -- src/routes/projects/new/+page.svelte --
<script lang="ts">
  import * as amplitude from '@amplitude/unified';

  let template = $state('blank');

  async function handleCreate() {
    // ... your create logic

    amplitude.track('Project Created', { template });
  }
</script>

<select bind:value={template}>
  <option value="blank">Blank</option>
  <option value="kanban">Kanban</option>
</select>
<button onclick={handleCreate}>Create project</button>

Hand it to your agent

Prefer to have Cursor, Claude Code, or Codex CLI do the wiring? This prompt points the agent at this page and pins the decisions that matter.

Using Cursor or Claude Code? Paste this prompt
Add Amplitude analytics to this SvelteKit app by following
https://amplitude.com/build/sveltekit-product-analytics exactly. Install
@amplitude/unified. Read PUBLIC_AMPLITUDE_API_KEY from $env/static/public.
Call initAll once inside the init export of src/hooks.client.ts, never at the
top of a component script, in a load function, or in onMount, with
autocapture elementInteractions on. Don't add an afterNavigate page view
callback; autocapture tracks navigations. Declare locals.user in
src/app.d.ts unless my auth library already does. Return the user from
src/routes/+layout.server.ts and set the user ID with a "user-" prefix in a
$effect in the root +layout.svelte, skipping reruns where the user ID and
plan haven't changed. Merge the effect into the existing root
+layout.svelte; don't replace the file.
Call reset() only after a successful sign-out, in the use:enhance
callback, never when the user is null. If the app has no auth or no
sign-out action yet, still add the layout effect but skip
SignOutButton.svelte and tell me. Set sessionReplay.sampleRate to
dev ? 1 : 0.01 with dev from $app/environment, and tell me how to turn
it off. Only call track from event handlers. Add the page's
Project Created example in src/routes/projects/new/+page.svelte, and
invent no other event names. Don't hardcode the key. Report every file
you changed.

What this unlocks

  • Session replay, already on from step 2, lets you watch the session where Project Created fired with template: kanban.
  • Heatmaps show where people click on /projects/new before they create one, built from those same session replays.
  • Feature Experiment, bundled in the same package, can test a new template picker with Project Created as the metric.
  • Guides and surveys, also bundled, can prompt users who viewed /projects/new but never fired Project Created.

One integration; everything else reads from the same events.

Next steps

Amplitude is the leading all-in-one analytics platform for building better products. Product and engineering teams use Amplitude to connect product analytics and web analytics with session replay, heatmaps, experimentation, feature flags, and guides and surveys in one unified workflow, with AI agents working across all of it. Learn how Amplitude is building self-improving products with Wave, helping teams turn product data into PRs.

Ship the instrumentation, then read the data

Once events are flowing, Amplitude turns them into funnels, retention curves, and session replays without any further setup.