---
title: "How to add guides and surveys to a SvelteKit app"
description: "Show Amplitude guides and surveys in a SvelteKit app: one client init, guide links through goto, stable pin targets, and a survey opened from a button."
canonical: "https://amplitude.com/en-us/build/sveltekit-guides-surveys"
---

# How to add guides and surveys to a SvelteKit app

Show Amplitude guides and surveys in a SvelteKit app: one client init, guide links through goto, stable pin targets, and a survey opened from a button.

Source: https://amplitude.com/en-us/build/sveltekit-guides-surveys

---

# How to add guides and surveys to a SvelteKit app

Turn on guides and surveys in a SvelteKit app, send guide links and buttons through SvelteKit's client router, anchor a pin to a component, and open a survey from your own button. About 20 minutes, all copy-paste.

- SvelteKit 2.10+
- Svelte 5
- Unified SDK 1.x
- 20 min

Chris Tam · Last updated 13 October 2026

SvelteKit renders each page on the server, then moves between pages in the browser without a reload. [Guides and surveys](https://amplitude.com/guides-and-surveys) walk users through those pages and ask for feedback in context, and you edit them in Amplitude instead of shipping a deploy.

In this tutorial, you'll turn on guides and surveys, route guide links through `goto`, anchor a pin, and open a survey from a button. It builds on [the SvelteKit product analytics page](/build/sveltekit-product-analytics).

## What you'll build

A SvelteKit app where guides and surveys boot once, in the browser, with the same user and device ID as your analytics, guide links marked **Use router** navigate with `goto`, a guide button runs an `open_new_project` callback in your code, a pin anchors to `[data-guide="new-project-button"]`, and a "Give feedback" button opens the survey with the key `product-feedback`. By the end you'll see the SDK report a booted user, and `Guide Viewed` and `Survey Submitted` in your own user's event stream.

## Prerequisites

- A SvelteKit 2.10 or later app with Svelte 5, with Amplitude already initialized in the `init` export of `src/hooks.client.ts` and the user identified from `src/routes/+layout.svelte`. If you don't have that yet, start with [How to add product analytics to a SvelteKit app](/build/sveltekit-product-analytics).
- Your Amplitude project API key. In Amplitude, go to Settings > Projects > your project > General. Guides and surveys must use the same key as your analytics.
- The `src/routes/+layout.server.ts` and `src/lib/components/SignOutButton.svelte` files from the product analytics page.

Guides and surveys availability depends on your plan; [compare plans](https://amplitude.com/pricing). No Amplitude account yet? [Sign up for free](https://app.amplitude.com/signup) to get your project API key.

## 1. Install the SDK and turn guides and surveys on

[The Quickstart](https://amplitude.com/docs/get-started/amplitude-quickstart) recommends `@amplitude/unified` for browser apps installed from npm, and the [Browser Unified SDK](https://amplitude.com/docs/sdks/analytics/browser/browser-unified-sdk) includes guides and surveys and initializes them by default. If you followed the product analytics page, it's already installed.

Terminal

```sh
npm install @amplitude/unified
```

.env

```sh
PUBLIC_AMPLITUDE_API_KEY=your-project-api-key
```

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.

Then turn the product on in Amplitude. The [Guides and Surveys Web SDK docs](https://amplitude.com/docs/sdks/guides-and-surveys/sdk) say to enable guides and surveys in your project settings before anything can display, and the project-level apps setting has to include web so it appears as a target when you create a guide or survey. Open Project Settings > Guides and Surveys and turn on both.

### If your app sends security headers

Skip this if your app doesn't send a `Content-Security-Policy` or `Cross-Origin-Opener-Policy` header; a new SvelteKit app sends neither. If yours does, the docs require two changes. In SvelteKit, a CSP usually comes from `kit.csp` in `svelte.config.js`, which quotes keywords like `self` for you, so add the Amplitude hosts to the directives you already have:

svelte.config.js (merge into your existing kit.csp.directives)

```javascript
csp: {
  directives: {
    'script-src': ['self', 'https://*.amplitude.com'],
    'connect-src': ['self', 'https://*.amplitude.com'],
    'img-src': ['self', 'https://*.amplitude.com'],
    'media-src': ['self', 'https://*.amplitude.com'],
    'style-src': ['self', 'https://*.amplitude.com'],
  },
```

`Cross-Origin-Opener-Policy` isn't part of `kit.csp`; it comes from your `handle` hook in `src/hooks.server.ts` or your host. `same-origin` blocks the messages Preview mode passes between Amplitude and your page, so previews never open; set it to `same-origin-allow-popups` instead. If you also run Web Experiment, its Visual Editor needs `unsafe-none`, which works for Preview too.

If your `style-src` blocks inline styles, guides and surveys can't draw. The Guides and Surveys SDK takes a `nonce` option, but SvelteKit's `kit.csp` adds a nonce to `style-src` only when it inlines a stylesheet itself, which a default build doesn't. The dependable setup is `'unsafe-inline'` in `style-src` (`'style-src': ['self', 'https://*.amplitude.com', 'unsafe-inline']`). If your security policy forbids that, have an engineer confirm a nonce setup before relying on it.

If you also run Web Experiment, read the `kit.csp` note on [the Web Experiment page](/build/sveltekit-web-experiment) before you add `'unsafe-inline'` to `script-src`: with it in that directive, SvelteKit stops adding a nonce or hash for its own startup script.

## 2. Initialize once in the client hook

Keep `initAll` where the product analytics page put it: the `init` export of `src/hooks.client.ts`, which runs once in the browser before the page hydrates. The init options match [the session replay page](/build/sveltekit-session-replay), including its masking rules, which the file below keeps. The one addition hands the `initAll` promise to a small module, so code that talks to guides and surveys can wait until it has finished. The SDK's `initAll` returns a promise, and `window.engagement` isn't ready to use until it settles.

This is the same ready module as [the SvelteKit feature flags page](/build/sveltekit-feature-flags). If you already have it, keep yours.

src/lib/amplitude-ready.ts

```typescript
// Resolves to true once initAll has finished in the browser, or false
// if analytics is off. Nothing awaits it on the server.
let markReady: (ok: boolean) => void = () => {};
export const amplitudeReady = new Promise<boolean>((resolve) => {
  markReady = resolve;
});

export function setAmplitudeReady(init: Promise<unknown> | null) {
  if (!init) {
    markReady(false);
    return;
  }
  init.then(
    () => markReady(true),
    (error) => {
      console.warn('[amplitude] initAll failed', error);
      markReady(false);
    },
  );
}
```

`window.engagement` isn't in TypeScript's `Window` type, and `window` doesn't exist during the server render. Handle both in one typed accessor that lists only the methods this page calls and returns `undefined` on the server.

src/lib/engagement.ts

```typescript
import { browser } from '$app/environment';

// The window.engagement methods this page calls, as the Guides and Surveys Web SDK docs define them.
type Engagement = {
  setRouter(fn: (url: string) => void): void;
  addCallback(name: string, fn: () => void): void;
  gs: {
    show(key: string, stepIndex?: number): void;
    closeAll(): void;
  };
  _debugStatus?: () => unknown;
};

export function getEngagement(): Engagement | undefined {
  // No window during the server render.
  if (!browser) return undefined;
  return (window as unknown as { engagement?: Engagement }).engagement;
}
```

Replace `src/hooks.client.ts` with the file below. Keep any options you added on other SvelteKit pages (for example `pageViews: { trackHistoryChanges: 'pathOnly' }` from /build/sveltekit-web-analytics), and only change the parts this page names: the `setAmplitudeReady` import and the two calls to it. If you added the `setAmplitudeReady(...)` wrapper on /build/sveltekit-feature-flags or /build/sveltekit-guides-surveys, keep it around `initAll`.

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';
import { setAmplitudeReady } from '$lib/amplitude-ready';

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

  // initAll starts analytics, session replay, Feature Experiment, and
  // guides and surveys. Guides and surveys boot with the analytics user.
  setAmplitudeReady(
    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,
        debugMode: dev,
        // From /build/sveltekit-session-replay. Keep your masking rules when you paste this file.
        privacyConfig: {
          maskSelector: ['[data-amp-mask]'],
          blockSelector: ['[data-amp-block]'],
          unmaskSelector: ['[data-amp-unmask]'],
        },
    }),
  );
};
```

**Why.** With the Unified SDK, guides and surveys initialize alongside analytics and boot with the same user and session. Don't call `engagement.init()` or `engagement.boot()` yourself, and don't initialize from a component's `onMount`. The docs say calling `boot` more than once causes unexpected behavior, especially for guides and surveys set to appear immediately. The client hook runs once per page load and never on the server. The `init` function doesn't return the `initAll` result, so SvelteKit doesn't hold hydration until guides and surveys finish loading. `amplitudeReady` resolves to `false` when the key is missing or `initAll` fails, so nothing waits forever, and a failure logs `[amplitude] initAll failed` in the console.

Session replay keeps the product analytics page's rate: every session in development, about 1% in production, which is where Amplitude recommends starting. `debugMode: dev`, also from the session replay page, ignores the focus check so replays record with DevTools open; it's off in production builds. Set `sampleRate` to `0` to turn session replay off. Don't pass `engagement: { skip: true }`, which stops guides and surveys from loading at all.

The accessor doesn't augment the global `Window` type, so it can't conflict with any declaration the SDK packages ship. Every file below calls `getEngagement()` instead of `window.engagement`.

## 3. Connect guide buttons to the SvelteKit router

A guide's **Visit link** action reloads the page unless you give the SDK a router. A **Run callback** action does nothing unless your code has registered a function with that name. Both run through `goto` from `$app/navigation`, so register them in one small function:

src/lib/engagement-bridge.ts

```typescript
import { goto } from '$app/navigation';
import { base } from '$app/paths';
import { amplitudeReady } from '$lib/amplitude-ready';
import { getEngagement } from '$lib/engagement';

export function registerEngagementBridge() {
  void amplitudeReady.then((ok) => {
    // Analytics is off or initAll failed.
    if (!ok) return;

    const engagement = getEngagement();
    if (!engagement) {
      console.warn('window.engagement not found; guide links will reload the page.');
      return;
    }

    // Guide links set to "Use router" call this instead of reloading.
    engagement.setRouter((url) => {
      const target = new URL(url, window.location.origin);
      const inApp =
        target.origin === window.location.origin &&
        (!base || target.pathname === base || target.pathname.startsWith(`${base}/`));

      // Other sites, and paths outside the app's base path, load normally.
      if (!inApp) {
        window.location.assign(target.href);
        return;
      }

      // goto takes the full path, base path included.
      void goto(target.pathname + target.search + target.hash);
    });

    // Guide buttons with "Run callback" and the name open_new_project.
    engagement.addCallback('open_new_project', () => {
      void goto(`${base}/projects/new`);
    });
}
```

Call it from `onMount` in the root layout. Step 6 shows the complete `src/routes/+layout.svelte`; the lines this step adds are:

src/routes/+layout.svelte (excerpt)

```html
<script lang="ts">
  import { onMount } from 'svelte';
  import { registerEngagementBridge } from '$lib/engagement-bridge';

  onMount(() => {
    registerEngagementBridge();
  });
</script>
```

Then, in the builder, set the URL behavior on every **Visit link** action to **Use router**, and enter `open_new_project` as the name on any **Run callback** action. The docs are explicit: **Same tab** and **New tab** ignore the router you set, and the callback name must match the string passed to `addCallback`.

**Why.** The docs' examples register `setRouter` from a component (a React effect, an Angular root component). The root layout is SvelteKit's equivalent: it mounts once per page load and stays mounted across client-side navigations, so the callback stays registered on every route, and `onMount` runs only in the browser. Waiting for `amplitudeReady` means `window.engagement` exists when `setRouter` runs. If `initAll` fails, `amplitudeReady` resolves to `false` and the bridge skips registration, and if `window.engagement` is still missing, the console warning tells you why guide links reload. `goto` only handles URLs inside your app, so the same-origin and base path check sends anything else through a normal page load. Unlike some routers, `goto` expects the base path in the URL, so the guide's full pathname passes through unchanged when you set `paths.base` in `svelte.config.js`. On SvelteKit 2.26 or later you can use `resolve('/projects/new')` from `$app/paths` instead of `${base}/projects/new`.

## 4. Keep identity stable so guides show once

Amplitude remembers which guides and surveys a user has seen, completed, or dismissed as part of that user's history. The docs warn that if a user's identity changes between visits, Amplitude treats them as new and shows the experience again. `amplitude.reset()` issues a new device ID, so calling it whenever the user is `null`, which is every anonymous page load, would show an anonymous visitor the same welcome guide on every visit.

The `$effect` in the root layout from the product analytics page already returns early when there's no user, so keep it as it is. Reset only from the sign-out form, and close anything still on screen first:

src/lib/components/SignOutButton.svelte

```html
<script lang="ts">
  import { enhance } from '$app/forms';
  import * as amplitude from '@amplitude/unified';
  import { getEngagement } from '$lib/engagement';
</script>

<form
  method="POST"
  action="/logout"
  use:enhance={() => {
    return async ({ result, update }) => {
      if (result.type === 'success' || result.type === 'redirect') {
        getEngagement()?.gs.closeAll(); // close anything still on screen
        amplitude.reset(); // clears the user ID and issues a new device ID
      }
      await update();
    };
  }}
>
  <button>Sign out</button>
</form>
```

Point `action` at your own sign-out action. If you followed the feature flags page, keep `amplitude.experiment()?.clear()` after `reset()`; this page only adds `closeAll()` before it.

**Why.** Guides and surveys read the same user as analytics, so targeting sees the user ID and `plan` that the layout effect sets. The default bucketing unit for rollouts is User ID, so set the user ID before you rely on a percentage rollout.

## 5. Give pins a target that survives a rebuild

Pins, tooltips, and card embeds attach to an element by CSS selector, and the docs recommend data attributes added specifically for guide targeting. A selector the visual picker builds from the hashed `svelte-` classes Svelte adds for scoped styles can change when the component's styles change, and then the pin stops rendering. Tag the elements you plan to point at:

src/routes/+page.svelte

```html
<script lang="ts">
  import { base } from '$app/paths';
</script>

<section data-guide="home">
  <h1>Your projects</h1>
  <a href="{base}/projects/new" data-guide="new-project-button">New project</a>
</section>
```

In the guide builder, add a Pin step and enter `[data-guide="new-project-button"]` as its CSS selector instead of the generated one.

**Why.** The docs say that if the target element isn't in the DOM when the step renders, the pin doesn't display, and the SDK doesn't move it to another position. In a SvelteKit app, anything inside an `{#await}` block for a streamed load promise, or behind an `{#if}` that waits for data, can render after the guide triggers. For those, set the trigger to **When element appears** with the same selector, so the guide fires when the element renders rather than when the page loads. On a later step in a tour, turn on **Skip if target not found**, which moves on if the target doesn't appear within 250 ms.

## 6. Open a survey from your own button

When a survey should open because someone clicked something, the docs recommend setting its trigger to **None** in the builder and calling `show` from the click handler.

src/lib/components/FeedbackButton.svelte

```html
<script lang="ts">
  import { getEngagement } from '$lib/engagement';

  function openFeedback() {
    getEngagement()?.gs.show('product-feedback');
  }
</script>

<button type="button" onclick={openFeedback}>Give feedback</button>
```

Render it in the root layout, next to the identity effect from the product analytics page and the bridge from step 3:

src/routes/+layout.svelte

```html
<script lang="ts">
  import { onMount } from 'svelte';
  import * as amplitude from '@amplitude/unified';
  import { registerEngagementBridge } from '$lib/engagement-bridge';
  import FeedbackButton from '$lib/components/FeedbackButton.svelte';

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

  // Guide links and Run callback buttons, registered once in the browser.
  onMount(() => {
    registerEngagementBridge();
  });

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

<header>
  <FeedbackButton />
</header>

{@render children()}
```

If you followed the feature flags page, keep its `refetchBrowserFlags` calls in the effect; this page only adds the `onMount` bridge and the `FeedbackButton`.

Replace `product-feedback` with your survey's key from the builder. A click before `initAll` has finished does nothing, because `getEngagement()` is still `undefined`; click again once the page has loaded. `show` ignores targeting rules and limits but still respects page targeting, so the survey's Where settings must allow every route where the button renders. Because the button lives in the root layout, that's every route in the app.

If you'd rather use the **On event tracked** trigger, the event has to fire in the browser. `Project Created`, tracked with `amplitude.track` from a click handler on the product analytics page, works. An event your form action or `+server.ts` endpoint sends from the server never reaches the guides and surveys SDK; call `show` from the client after that request resolves instead, for example in the `use:enhance` callback.

**MCP.** Working in Cursor or Claude Code? Amplitude's MCP server includes `list_guides_surveys` and `get_guide_or_survey`, which list your project's guides and surveys and return each one's page targeting and trigger configuration, so your agent can check that the key it wires into `show` exists and is allowed on that route. [Set up the MCP server](https://amplitude.com/docs/amplitude-ai/amplitude-mcp).

## Verify it worked

Run `npm run dev` and sign in as a test user. In Amplitude, create a guide with a Pin step on `[data-guide="new-project-button"]`, set to **Open by default**, and a button whose action is **Run callback** with `open_new_project`, and a survey with the key `product-feedback` and the trigger **None**. Add your test user under **Test users** and set both to **Testing**, or click **Preview** and enter your local URL (the dev server's default is `http://localhost:5173`). Then, in the app, open the pin, click its button, and submit the survey from **Give feedback**. Check in this order:

1. **Network tab.** Filter for `amplitude.com`. You should see requests to `gs.amplitude.com`, the default guides and surveys host, and the analytics SDK's `POST` to `api2.amplitude.com/2/httpapi` returning `200`. None of them start until the page loads in the browser; the server render sends nothing.
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. Within ten seconds to a minute the event stream shows `Guide Viewed` for the pin (a pin that isn't open by default skips `Guide Viewed` when someone clicks its target), and `Survey Viewed` and `Survey Submitted` for the survey you answered. Guides and surveys events don't count toward your event volume.
4. **Browser console.** `window.engagement` returns an object, not `undefined`. Run `window.engagement._debugStatus()`: `user.user_id` should be your `user-` prefixed ID, `apiKey` should be set, `stateInitialized` and `decideSuccessful` should be `true`, and `num_guides_surveys` should be non-zero on the home page.
5. **Amplitude Chrome extension.** The Guides & Surveys tab shows four setup checks: **Installed**, **Initialized**, **Analytics Connected**, and **Booted**. Let the page finish loading first, since the tab doesn't update live.
6. **In the app.** The pin appeared on **New project**, its button navigated to `/projects/new` without a document request in the Network tab, and **Give feedback** opened the survey.

A guide you already completed or dismissed won't show again. Open your user profile, go to the Guides or Surveys tab, and select **Clear history** for that experience, or click **Reset User History** in the preview bar.

## Troubleshooting

### `window.engagement` is undefined

Guides and surveys never initialized. Confirm `src/hooks.client.ts` exports `init` and calls `initAll`, that `PUBLIC_AMPLITUDE_API_KEY` is set (the dev server reads `.env` only at startup, so restart it), and that nothing passes `engagement: { skip: true }`.

### "window is not defined" during server rendering

Something reads `window.engagement` outside the browser, such as a call at the top of a component's `<script>` or in a load function. Use `getEngagement()`, which returns `undefined` on the server, and call it from `onMount`, `$effect`, or an event handler.

### A guide set to Immediately shows twice, or not at all

Something calls `engagement.boot()` or `engagement.init()` on top of `initAll`, often in an `onMount` that runs every time a component mounts. Delete those calls.

### Anonymous visitors see the same welcome guide on every visit

`amplitude.reset()` runs while the user is signed out and issues a new device ID, so every visit looks like a new user. Keep the early return in the layout `$effect` and call `reset()` only in the sign-out `use:enhance` callback.

### The pin never appears, but the survey button works

The target element isn't in the DOM when the step renders, or the selector no longer matches. Check `document.querySelector('[data-guide="new-project-button"]')` in the console, and for late-rendered elements use the **When element appears** trigger.

### Guide links reload the whole page

The link's URL behavior isn't **Use router**, `registerEngagementBridge()` never runs in the root layout's `onMount`, or the console shows `window.engagement not found`. Run `Object.keys(window.engagement._.callbacks)` in the console; `engagement-router` should be in the list once `setRouter` has run.

### A Run callback button does nothing

The name in the builder doesn't match the `addCallback` string. Run `Object.keys(window.engagement._.callbacks)` in the console; `open_new_project` should be in the list, next to internal entries like `engagement-router`.

### A guide shows on some pages but not on the same page with a trailing slash

Where rules match the exact URL, including trailing slashes. If you set `trailingSlash` in a layout or page, write the rule to match the URLs SvelteKit actually serves, or use **URL/screen starts with**.

### Guide links reload and the console shows `[amplitude] initAll failed`

`initAll` rejected, so `amplitudeReady` resolved to `false` and the bridge never registered. A blocked guides and surveys or session replay script, for example from an ad blocker, can cause this. Allow the Amplitude hosts, or test in a browser profile without extensions.

### Preview never opens on your page

Your app sends `Cross-Origin-Opener-Policy: same-origin`, which blocks the messages Preview mode depends on. Set it to `same-origin-allow-popups`, as in step 1.

## Full code reference

Every file from this tutorial in one block, the version to hand to an agent. 10 files, plus notes on the files you already have from the product analytics page.

10 files, 216 lines

```typescript
// -- .env --
PUBLIC_AMPLITUDE_API_KEY=your-project-api-key

// -- src/lib/amplitude-ready.ts --
let markReady: (ok: boolean) => void = () => {};
export const amplitudeReady = new Promise<boolean>((resolve) => {
  markReady = resolve;
});

export function setAmplitudeReady(init: Promise<unknown> | null) {
  if (!init) {
    markReady(false);
    return;
  }
  init.then(
    () => markReady(true),
    (error) => {
      console.warn('[amplitude] initAll failed', error);
      markReady(false);
    },
  );
}

// -- src/lib/engagement.ts --
import { browser } from '$app/environment';

type Engagement = {
  setRouter(fn: (url: string) => void): void;
  addCallback(name: string, fn: () => void): void;
  gs: {
    show(key: string, stepIndex?: number): void;
    closeAll(): void;
  };
  _debugStatus?: () => unknown;
};

export function getEngagement(): Engagement | undefined {
  if (!browser) return undefined;
  return (window as unknown as { engagement?: Engagement }).engagement;
}

// -- 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';
import { setAmplitudeReady } from '$lib/amplitude-ready';

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

  setAmplitudeReady(
    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,
        debugMode: dev,
        // From /build/sveltekit-session-replay. Keep your masking rules when you paste this file.
        privacyConfig: {
          maskSelector: ['[data-amp-mask]'],
          blockSelector: ['[data-amp-block]'],
          unmaskSelector: ['[data-amp-unmask]'],
        },
    }),
  );
};

// -- src/lib/engagement-bridge.ts --
import { goto } from '$app/navigation';
import { base } from '$app/paths';
import { amplitudeReady } from '$lib/amplitude-ready';
import { getEngagement } from '$lib/engagement';

export function registerEngagementBridge() {
  void amplitudeReady.then((ok) => {
    if (!ok) return;

    const engagement = getEngagement();
    if (!engagement) {
      console.warn('window.engagement not found; guide links will reload the page.');
      return;
    }

    engagement.setRouter((url) => {
      const target = new URL(url, window.location.origin);
      const inApp =
        target.origin === window.location.origin &&
        (!base || target.pathname === base || target.pathname.startsWith(`${base}/`));

      if (!inApp) {
        window.location.assign(target.href);
        return;
      }

      void goto(target.pathname + target.search + target.hash);
    });

    engagement.addCallback('open_new_project', () => {
      void goto(`${base}/projects/new`);
    });
}

// -- 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 { onMount } from 'svelte';
  import * as amplitude from '@amplitude/unified';
  import { registerEngagementBridge } from '$lib/engagement-bridge';
  import FeedbackButton from '$lib/components/FeedbackButton.svelte';

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

  onMount(() => {
    registerEngagementBridge();
  });

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

<header>
  <FeedbackButton />
</header>

{@render children()}

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

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

// -- src/lib/components/FeedbackButton.svelte --
<script lang="ts">
  import { getEngagement } from '$lib/engagement';

  function openFeedback() {
    getEngagement()?.gs.show('product-feedback');
  }
</script>

<button type="button" onclick={openFeedback}>Give feedback</button>

// -- src/routes/+page.svelte --
// Example: add the data-guide attributes to your own markup.
<script lang="ts">
  import { base } from '$app/paths';
</script>

<section data-guide="home">
  <h1>Your projects</h1>
  <a href="{base}/projects/new" data-guide="new-project-button">New project</a>
</section>

// -- src/routes/projects/new/+page.svelte --
// Your existing page from /build/sveltekit-product-analytics, which tracks
// Project Created from its click handler. The open_new_project callback
// navigates here.

// -- src/app.d.ts and src/hooks.server.ts --
// Unchanged from /build/sveltekit-product-analytics. App.Locals must declare
// user (with id and plan) for +layout.server.ts to type-check.
```

## 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
First complete https://amplitude.com/build/sveltekit-product-analytics, or
confirm this app already has its setup: initAll in the init export of
src/hooks.client.ts, user typed on App.Locals in src/app.d.ts,
src/routes/+layout.server.ts returning user { id, plan }, the identity
$effect in src/routes/+layout.svelte, and SignOutButton.svelte. Then add
Amplitude guides and surveys by following
https://amplitude.com/build/sveltekit-guides-surveys exactly. Use
@amplitude/unified and keep the single initAll call in src/hooks.client.ts,
keeping any sessionReplay.privacyConfig and other options already there. If
you added the setAmplitudeReady(...) wrapper on
/build/sveltekit-feature-flags or /build/sveltekit-guides-surveys, keep it
around initAll. Otherwise create src/lib/amplitude-ready.ts from the page
and pass the initAll promise to setAmplitudeReady() (call
setAmplitudeReady(null) when the key is missing) without returning it from
init. Never call engagement.init() or engagement.boot(), and never
initialize in onMount, a component script, or a load function. Read
window.engagement only through a typed getEngagement() helper in
src/lib/engagement.ts that returns undefined on the server; don't augment
the global Window type. Add registerEngagementBridge() in
src/lib/engagement-bridge.ts, called from onMount in the root
+layout.svelte, that waits for the amplitudeReady promise, returns if it
resolves false, warns in the console if window.engagement is missing, calls
setRouter with goto for same-origin URLs inside the app's base path, and
registers addCallback('open_new_project', ...) that calls
goto(`${base}/projects/new`). Add src/lib/components/FeedbackButton.svelte
that calls getEngagement()?.gs.show('product-feedback') on click, and
render it in a <header> in the root layout. Add
data-guide="new-project-button" to the New project link on the home page
(src/routes/+page.svelte), keeping my existing markup; if the app has no
home page link to /projects/new, add one. Keep the layout's identity
$effect as it is, including its lastIdentity check and any code other
SvelteKit pages added. In the sign-out use:enhance callback, call
getEngagement()?.gs.closeAll() before amplitude.reset(), keep any existing
lines there, and call reset() only after a successful sign-out, never when
the user is null. If kit.csp blocks inline styles, tell me rather than
inventing a nonce workaround. Read PUBLIC_AMPLITUDE_API_KEY from
$env/static/public and don't hardcode it. Tell me session replay records
every session in development and about 1% in production, and how to turn it
off. Report every file you changed.
```

## What this unlocks

- [Session replay](https://amplitude.com/session-replay), on at the sample rate from step 2, lets you watch what users did right after they submitted `product-feedback`.
- [Heatmaps](https://amplitude.com/heatmaps) are built from session replays, so they show whether people click `new-project-button` more after the pin goes live.
- [Feature Experiment](https://amplitude.com/amplitude-experiment), bundled in the same package, can gate the feature a guide announces, so the flag and the guide reach the same users.
- [Product analytics](https://amplitude.com/amplitude-analytics) charts `Guide Viewed` next to `Project Created`, so you can see whether the pin moves project creation.

One integration; everything else reads from the same events.

## Next steps

[Web frameworksHow to add feature flags and experiments to a SvelteKit appGate the feature a guide announces behind a flag, evaluated on the server for the first paint.](/build/sveltekit-feature-flags)[Web frameworksHow to add session replay to a SvelteKit appRecord the sessions around a guide or survey, with the masking rules this page keeps.](/build/sveltekit-session-replay)[Web frameworksHow to add heatmaps to a SvelteKit appSee where people click before and after a pin goes live.](/build/sveltekit-heatmaps)

Reference docs

[Guides and Surveys Web SDK →](https://amplitude.com/docs/sdks/guides-and-surveys/sdk)[Browser Unified SDK →](https://amplitude.com/docs/sdks/analytics/browser/browser-unified-sdk)[Setup and Targeting →](https://amplitude.com/docs/guides-and-surveys/setup-and-target)[Testing and Publishing →](https://amplitude.com/docs/guides-and-surveys/testing)[Guide or Survey not showing →](https://amplitude.com/docs/guides-and-surveys/troubleshooting/guide-or-survey-not-showing)[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-guides-surveys.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)
