---
title: "How to add product analytics to a SvelteKit app"
description: "Install Amplitude in a SvelteKit app from the client init hook, capture page views on every navigation, identify signed-in users, and confirm events land."
canonical: "https://amplitude.com/en-us/build/sveltekit-product-analytics"
---

# How to add product analytics to a SvelteKit app

Install Amplitude in a SvelteKit app from the client init hook, capture page views on every navigation, identify signed-in users, and confirm events land.

Source: https://amplitude.com/en-us/build/sveltekit-product-analytics

---

# 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](https://amplitude.com/amplitude-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](https://app.amplitude.com/signup) to get your project API key. Your session replay capture quota depends on your plan; [compare plans](https://amplitude.com/pricing).

## 1. Install the SDK and add the key

[The Quickstart](https://amplitude.com/docs/get-started/amplitude-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

```sh
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

```sh
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

```typescript
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](https://amplitude.com/docs/sdks/analytics/browser/browser-unified-sdk).

## 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

```typescript
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

```typescript
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

```html
<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

```html
<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

```html
<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](/build/nodejs-product-analytics) 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](https://amplitude.com/docs/amplitude-ai/amplitude-mcp).

## 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

```typescript
// -- .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

```text
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](https://amplitude.com/session-replay), already on from step 2, lets you watch the session where `Project Created` fired with `template: kanban`.
- [Heatmaps](https://amplitude.com/heatmaps) show where people click on `/projects/new` before they create one, built from those same session replays.
- [Feature Experiment](https://amplitude.com/amplitude-experiment), bundled in the same package, can test a new template picker with `Project Created` as the metric.
- [Guides and surveys](https://amplitude.com/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

[Web frameworksHow to add session replay to a SvelteKit appTune the sample rate and mask sensitive components before you record real users.](/build/sveltekit-session-replay)[Web frameworksHow to add feature flags and experiments to a SvelteKit appRender the right variant in the first HTML and use \`Project Created\` as the experiment's metric.](/build/sveltekit-feature-flags)[Use caseHow to stitch client and server events to one userKeep browser events and your form actions' server events on one profile through sign-up.](/build/stitch-client-server-events)

Reference docs

[Amplitude Quickstart →](https://amplitude.com/docs/get-started/amplitude-quickstart)[Browser Unified SDK →](https://amplitude.com/docs/sdks/analytics/browser/browser-unified-sdk)[Browser SDK 2 →](https://amplitude.com/docs/sdks/analytics/browser/browser-sdk-2)[Configure session replay sampling →](https://amplitude.com/docs/session-replay/configure-sampling)[Debug analytics →](https://amplitude.com/docs/analytics/debug-analytics)[Amplitude MCP server →](https://amplitude.com/docs/amplitude-ai/amplitude-mcp)

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

For agents

[View raw .md](/build/sveltekit-product-analytics.md)[llms.txt](/llms.txt)

## 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.

[Create a free account](https://app.amplitude.com/signup)[Browse all Build tutorials](https://amplitude.com/build)
