---
title: "How to add Web Experiment to a SvelteKit app"
description: "Load Amplitude Web Experiment in a SvelteKit app without flicker, keep variants intact through hydration and navigation, and confirm impressions land."
canonical: "https://amplitude.com/en-us/build/sveltekit-web-experiment"
---

# How to add Web Experiment to a SvelteKit app

Load Amplitude Web Experiment in a SvelteKit app without flicker, keep variants intact through hydration and navigation, and confirm impressions land.

Source: https://amplitude.com/en-us/build/sveltekit-web-experiment

---

# How to add Web Experiment to a SvelteKit app

Load the Amplitude Web Experiment script in src/app.html so it runs before SvelteKit hydrates, give the elements you want to test stable selectors, and confirm \[Experiment] Impression events land. About 15 minutes, all copy-paste.

- SvelteKit 2.10+
- Svelte 5
- Unified SDK 1.x
- Web Experiment script
- 15 min

Chris Tam · Last updated 13 October 2026

SvelteKit renders each page on the server, then hands navigation to its client router. [Web Experiment](https://amplitude.com/web-experimentation) lets you A/B test the headlines, buttons, and layouts on those pages from a visual editor, so a test doesn't need a code change and a deploy.

In this tutorial, you'll load the Web Experiment script in `src/app.html`, make Svelte elements survive hydration, and launch your first experiment. It builds on [SvelteKit product analytics](/build/sveltekit-product-analytics).

## What you'll build

A SvelteKit app that loads `API_KEY.experiment.js` synchronously at the top of `src/app.html`, applies a variant to the home page hero on the server-rendered page and again after client-side navigation, and sends `[Experiment] Impression` with `[Experiment] Flag Key` and `[Experiment] Variant` through the analytics SDK you already run. `Project Created` from the product analytics page becomes the experiment's goal. By the end you'll see an impression in your own user's event stream in Amplitude.

## Prerequisites

- A SvelteKit 2.10 or later app with Svelte 5, with Amplitude already initialized in the `init` export of `src/hooks.client.ts`, identity set in `src/routes/+layout.svelte`, and `reset()` called only from your sign-out form. 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. Web Experiment uses the same project key as your analytics.
- Your app running at a URL you can open in Chrome, since the Visual Editor loads your real page.

No Amplitude account yet? A free account is enough for this tutorial; the docs list A/B testing and the Visual Editor on all plans, and this page doesn't use custom code actions, which need Growth or Enterprise. [Sign up for free](https://app.amplitude.com/signup) to get your project API key.

## 1. Pick the standalone experiment script

Web Experiment ships as a script tag, not an npm package. The [Browser Unified SDK docs](https://amplitude.com/docs/sdks/analytics/browser/browser-unified-sdk) are explicit: `@amplitude/unified` from npm includes Feature Experiment, the code-based flags product, and doesn't include Web Experiment. Installing more packages won't give you the Visual Editor.

Two script tags contain Web Experiment, and on a SvelteKit app with an npm-installed SDK only one is right. [Implement Web Experiment](https://amplitude.com/docs/web-experiment/implementation) documents the first, and the Unified SDK docs document the second:

- `https://cdn.amplitude.com/script/API_KEY.experiment.js` loads Web Experiment only. Use it when you already initialize analytics from npm, as the SvelteKit product analytics page does.
- `https://cdn.amplitude.com/script/API_KEY.js`, the Unified Script, loads analytics, session replay, and Web Experiment together. Use it only on a site with no Amplitude SDK installed.

Use `.experiment.js`. The docs say it tracks impressions through the Browser SDK already on your site, so it needs no analytics of its own. Adding the Unified Script next to `@amplitude/unified` puts a second analytics SDK on the page.

The script URL takes your project API key, which you already have in `.env`. The key is public and its scope is limited to the minimum needed to ingest data, so it's expected in your HTML; keep it in the env file anyway.

.env

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

## 2. Load it first in src/app.html

`src/app.html` is the page template SvelteKit wraps around every server-rendered response, so it's the one place a tag reaches the browser ahead of everything SvelteKit adds. Put the script at the top of `<head>`, right after `<meta charset>` and above `%sveltekit.head%`, which is where SvelteKit inserts its stylesheets and startup scripts. SvelteKit replaces `%sveltekit.env.PUBLIC_AMPLITUDE_API_KEY%` with the variable's value when it renders the page.

src/app.html (add the \<script> line; keep the rest of your app.html)

```html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <!-- Amplitude Web Experiment. Keep it first after the charset, and synchronous.
         EU projects: https://cdn.eu.amplitude.com/script/ -->
    <script src="https://cdn.amplitude.com/script/%sveltekit.env.PUBLIC_AMPLITUDE_API_KEY%.experiment.js"></script>
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    %sveltekit.head%
  </head>
  <body data-sveltekit-preload-data="hover">
    <div style="display: contents">%sveltekit.body%</div>
  </body>
</html>
```

**Why.** The docs ask for the script "as high as possible" in `<head>` and call the synchronous tag the best experience. In SvelteKit that means `app.html`, not `<svelte:head>`: anything in `<svelte:head>` lands inside `%sveltekit.head%`, mixed in with SvelteKit's own tags, and only on the routes that render it. Don't load it from `onMount`, a `$effect`, or `src/hooks.client.ts` either. Each of those runs after the browser has parsed and painted the server-rendered HTML, so visitors see the original content before the variant replaces it. Don't add `async` or `defer` to the tag.

`%sveltekit.env.*%` only resolves variables that start with `PUBLIC_`, and it resolves to an empty string when the variable is missing. It reads the environment when the page renders, while the base page's `$env/static/public` import is fixed at build time, so in production set `PUBLIC_AMPLITUDE_API_KEY` both where you build and where the server runs. Prerendered pages resolve it at build time.

The base script is about 20 KB compressed plus roughly 100 bytes per experiment, and the browser serves it from cache for 60 seconds across page loads. Archive finished experiments so it doesn't keep growing.

### If you can't ship a blocking script

If your performance budget forbids synchronous third-party scripts, the docs offer an async loader with an anti-flicker mask. It hides the page with a style rule, loads the script asynchronously, and clears the mask when Amplitude applies changes or when the timeout expires. Use it in place of the `<script src>` above, in the same spot:

src/app.html (async alternative, inside \<head>, first after the charset)

```html
<script>
  (function (d, h) {
    var apiKey = "%sveltekit.env.PUBLIC_AMPLITUDE_API_KEY%";
    // Milliseconds before the mask clears even if the script is slow.
    var timeout = 1000;
    var id = "amp-exp-css";
    try {
      if (!d.getElementById(id)) {
        var st = d.createElement("style");
        st.id = id;
        st.innerText =
          "* { visibility: hidden !important; background-image: none !important; }";
        h.appendChild(st);
        window.setTimeout(function () {
          st.remove();
        }, timeout);
        var sc = d.createElement("script");
        sc.src =
          "https://cdn.amplitude.com/script/" + apiKey + ".experiment.js";
        sc.async = true;
        sc.onerror = function () {
          st.remove();
        };
        h.insertBefore(sc, d.currentScript || h.lastChild);
      }
    } catch (e) {
      console.error(e);
    }
  })(document, document.head);
</script>
```

The trade is visible: every visitor sees a blank page for up to `timeout` milliseconds instead of a flash of the original. The synchronous tag is the better default. If you use `kit.csp`, the `unsafe-inline` that step 5 adds for the Visual Editor also covers this inline loader, so it needs no nonce. Don't put `%sveltekit.nonce%` in `src/app.html`: SvelteKit refuses to prerender any page when the template contains it. Don't load Web Experiment through Google Tag Manager in production; the docs warn that tag managers load it asynchronously and cause flicker.

## 3. Keep analytics in the client hook, and keep the device ID stable

The experiment script sends impressions through the analytics SDK you already initialize, so there's no second `initAll` and no second key. `src/hooks.client.ts` stays as it is, including the masking options from [SvelteKit session replay](/build/sveltekit-session-replay) if you added them. That config records every session in development and about 1% in production with session replay; set `sessionReplay.sampleRate` to `0` if you don't want replays.

Check one thing that matters more for experiments than for analytics. The docs say most web experiments bucket by device ID, and `reset()` issues a new device ID. The base page's layout effect returns early when there's no user, and only the sign-out form's `use:enhance` callback calls `reset()`. Keep it that way. If you've added a `reset()` call anywhere that runs for signed-out visitors, every anonymous visit gets a fresh device ID and a fresh roll of the dice.

This is the base page's effect, for reference; if other SvelteKit pages extended it, keep your version.

src/routes/+layout.svelte

```html
<script lang="ts">
  import * as amplitude from '@amplitude/unified';

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

  // 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()}
```

`src/lib/components/SignOutButton.svelte` from the base page, which calls `reset()` only after a successful sign-out, stays as it is.

### Optional: event-triggered pages and behavioral targeting

If an experiment uses the **On Event Tracked** page trigger or real-time behavioral targeting, the experiment client needs to hear your analytics events. The docs say that when analytics and Web Experiment don't load together through the Unified Script, you register the experiment plugin on the analytics instance with `window.amplitude.add(window.webExperiment.plugin())`. With the npm SDK, make the same call on your imported instance, right after `initAll`. This is the complete updated `src/hooks.client.ts`. 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 `WebExperimentGlobal` type and the plugin registration after `initAll`. If you added the `setAmplitudeReady(...)` wrapper on /build/sveltekit-feature-flags or /build/sveltekit-guides-surveys, keep it around `initAll`, along with its import and the `setAmplitudeReady(null)` line in the missing-key branch. The `debugMode` and `privacyConfig` lines come from the session replay page, so a paste doesn't drop your masking.

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';

type WebExperimentGlobal = {
  plugin: () => Parameters<typeof amplitude.add>[0];
};

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: {
      // 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]'],
      },
  });

  // Lets On Event Tracked triggers and behavioral targeting see your events.
  const webExperiment = (window as Window & { webExperiment?: WebExperimentGlobal })
    .webExperiment;
  if (webExperiment) {
    amplitude.add(webExperiment.plugin());
  }
};
```

The synchronous tag in `<head>` has already run by the time SvelteKit starts and calls `init`, so `window.webExperiment` exists. With the async loader it may not have loaded yet, and the `if` skips the plugin without warning. Skip this block entirely if your experiments use the default **Immediately** trigger.

## 4. Make Svelte elements survive hydration and navigation

The Visual Editor records each change against a CSS selector, and the script applies it to whatever matches that selector in the live DOM. On a SvelteKit page, the script edits the server-rendered HTML, and Svelte hydrates that same HTML and takes it over. Three things decide whether a variant sticks.

Give anything you plan to test a stable attribute. The docs warn that the editor picks the shortest unique path, which can include generated names that change between builds. Svelte produces one of those: the `svelte-` class with a hash that a component's `<style>` block adds to every element it styles, which changes when the component's CSS changes. A `data-` attribute you write yourself doesn't change:

src/routes/+page.svelte

```html
<section data-exp="home-hero">
  <h1 data-exp="home-headline">Plan your next project in minutes</h1>
  <p data-exp="home-subhead">Boards, roadmaps, and templates in one place.</p>
  <a href="/projects/new" data-exp="home-cta">Create a project</a>
</section>
```

In the editor's Selector field, replace the generated path with `[data-exp="home-headline"]`. Put the attribute on plain HTML elements. On your own components, `data-exp` is just another prop, and it only reaches the DOM if the component spreads its remaining props onto an element.

Test content Svelte doesn't rewrite. Element changes edit an element's text or append styles to it, so the change lives on that DOM node. Static text written into the markup, like the headline above, renders once on the server and Svelte adopts it during hydration without touching it, which makes it the safe target. The whole text of the element has to be static; `Hi {name}` counts as an expression. Text from an expression, such as `{data.headline}`, belongs to Svelte, which writes its own value back during hydration or on the next update. Anything that replaces the node drops the change too: an `{#if}` that flips, a `{#key}` block, or an `{#each}` that re-renders. The docs' own check is to confirm JavaScript doesn't reset your changes after they apply. Avoid **Move** and the remove-from-DOM **Display** change on server-rendered markup. Svelte hydrates by walking the server HTML in order, so a moved or missing element can make it re-render the whole app in the browser, without your change, or attach its updates to the wrong elements. For content driven by data or state, use a [feature flag](/build/sveltekit-feature-flags) instead.

Wait for elements that render late. The script evaluates on the first page load and every time history state is pushed or popped, which covers `<a href>` clicks and `goto()`. On each change it reverts what it applied, then re-evaluates for the new URL. SvelteKit updates the URL just before it renders the next route, and anything inside an `{#await}` block for streamed data, an `{#if}` on client state, or a page transition renders later still, so elements can appear after evaluation. If a change doesn't show after client-side navigation, set the Page's trigger to **When Element Appears** with the element's selector. Page triggers are in Early Access. Shallow routing with `pushState` from `$app/navigation` is a history change too, so a modal that changes the URL reverts the variant underneath it unless the Page targeting also matches the modal's URL.

**Why.** On the first load, a SvelteKit page arrives as finished HTML and the synchronous script is already running before any of SvelteKit's scripts, so the variant can apply as soon as the element exists, with no client-side render to wait for. That's the advantage over a client-rendered app. The cost is that Svelte owns that HTML once it hydrates, so the variant survives only where Svelte has nothing of its own to write: static text, attributes Svelte doesn't control, and the structure it rendered on the server.

If you set `router: { type: 'hash' }` in `svelte.config.js` (SvelteKit 2.14+), your app renders in the browser and your routes live after the `#`. The **URL Matches** targeting rule ignores hash fragments, so it can't tell `/#/pricing` from `/#/`. Target those Pages with **URL Matches Pattern** and a wildcard after the hash path, for example `https://your-domain.com/#/pricing*`.

On routes with `export const ssr = false`, Svelte builds the page in the browser after the script runs, so use **When Element Appears** for anything you test there.

## 5. Open your headers to the script and editor

Skip this step if your app doesn't send a Content Security Policy or a Cross-Origin-Opener-Policy. SvelteKit sends neither unless you configure `kit.csp` in `svelte.config.js`, set headers in `src/hooks.server.ts`, or your host adds them. Per the docs, two changes are required.

Add `*.amplitude.com` and `'unsafe-inline'` to your existing `script-src` directive, and to `script-src-elem` if your policy has one, so the script and the Visual Editor can load. Remove `Cross-Origin-Opener-Policy`, or set it to `unsafe-none`, so the Visual Editor can open your site. If you set these headers in your server hook, edit them in the `handle` you already have, after your auth code. This adds the two values to your existing `script-src` instead of replacing your policy:

src/hooks.server.ts

```typescript
import type { Handle } from '@sveltejs/kit';

const AMPLITUDE_SCRIPT_SOURCES = " *.amplitude.com 'unsafe-inline'";

export const handle: Handle = async ({ event, resolve }) => {
  // ... your existing auth code that sets event.locals.user

  const response = await resolve(event);

  // Pages only. API and redirect responses can have read-only headers.
  if (!response.headers.get('content-type')?.startsWith('text/html')) return response;

  // Add Amplitude to the script-src (and script-src-elem) you already send; don't replace the policy.
  const csp = response.headers.get('Content-Security-Policy');
  if (csp) {
    response.headers.set(
      'Content-Security-Policy',
      csp.replace(
        /(^|;\s*)(script-src(?:-elem)?)(?![-\w])([^;]*)/g,
        `$1$2$3${AMPLITUDE_SCRIPT_SOURCES}`,
      ),
    );
  }
  response.headers.set('Cross-Origin-Opener-Policy', 'unsafe-none');
  return response;
};
```

If your policy has only `default-src`, add a `script-src` directive first; this code only extends directives that exist. When a policy also has `script-src-elem`, that directive governs `<script>` tags, so the code extends both. This rewrites headers your own hook or code sets. For `kit.csp`, edit `svelte.config.js` instead (see below). The `content-type` check skips API and redirect responses, whose headers can be read-only. `unsafe-none` turns off cross-origin isolation, so if your app relies on COOP and COEP for `SharedArrayBuffer`, send it only on the pages you test.

If you use `kit.csp`, add the values in `svelte.config.js` instead of rewriting the header in the hook: `'script-src': ['self', '*.amplitude.com', 'unsafe-inline']` (SvelteKit adds the quotes around keywords). When `script-src` contains `unsafe-inline`, SvelteKit stops adding a nonce or hash for its own startup script, so the browser honors `unsafe-inline`. That also means the policy no longer blocks injected inline scripts, so check with whoever owns your security policy. If your policy uses `strict-dynamic`, browsers ignore both host allowlists and `unsafe-inline`, and this setup won't load. Don't also run the hook rewrite on a `kit.csp` header: appending to a directive that already carries SvelteKit's nonce is exactly the case where browsers ignore `unsafe-inline`. Prerendered pages get `kit.csp` as a `<meta>` tag, which only the config can change. `unsafe-none` also covers guides and surveys Preview, so you don't need a second value. The editor's Mobile Simulator loads your site in an iframe, so `X-Frame-Options: DENY` or `frame-ancestors 'none'` blocks it.

## 6. Create the experiment in the Visual Editor

Run `npm run dev` or deploy, then create the experiment in Amplitude, following [Set up a web experiment](https://amplitude.com/docs/web-experiment/set-up-a-web-experiment):

1. In Amplitude Experiment, go to Experiments > Create Experiment > Web Experiment.
2. Name the experiment, enter the URL of your home page, and select your project. Amplitude uses the URL to create your first Page.
3. When the Visual Editor opens your page, click **Element Changes**, click the headline, and edit its text. Open the Selector and set it to `[data-exp="home-headline"]`. Changes apply to the current variant as you go.
4. Click **Continue**. Leave the Page targeting on your home page URL and the audience on all users.
5. Define your goals. Choose `Project Created`, the event the product analytics page tracks, as the primary metric.
6. Click **Test & Preview** and open the preview link to check the treatment, then start the experiment.

If Amplitude opens a Site Setup panel instead of the editor, it didn't find the script on that URL, or an ad blocker in your browser stopped it. After you save changes, allow up to 60 seconds for caches to refresh before you check the live page.

**MCP.** Working in Cursor or Claude Code? Amplitude's MCP server lets your agent read your project's existing event taxonomy, so when you pick a goal it points at `Project Created` instead of inventing `project_created`. [Set up the MCP server](https://amplitude.com/docs/amplitude-ai/amplitude-mcp).

## Verify it worked

Open your home page in a normal Chrome window with ad blockers off, click **Create a project** (the `/projects/new` page from the product analytics page) and go back, then check in this order:

1. **Network tab.** Filter for `amplitude.com`. You should see `API_KEY.experiment.js` from `cdn.amplitude.com` return `200` with your real key in the URL, not an empty segment like `/script/.experiment.js`, listed before SvelteKit's own scripts. Then look for the analytics SDK's `POST` to `api2.amplitude.com/2/httpapi`. If you're in the experiment, the Instrumentation Explorer Chrome extension shows an event with `event_type` `$impression`, the form the script sends before Amplitude transforms it. Reload with DevTools open and check the console for a `hydration_mismatch` warning or other errors; there shouldn't be any. A clean console doesn't rule out every structural problem, so also confirm the variant is still showing after the page finishes loading.
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 or your device ID. Within ten seconds to a minute the event stream shows `[Experiment] Impression`, with `[Experiment] Flag Key` naming your experiment and `[Experiment] Variant` naming the variant you saw.

No impression and no variant? You may be in control or outside the rollout. A private window gets a new device ID and a new roll. The [Amplitude Chrome extension](https://amplitude.com/docs/data/chrome-extension-debug) shows page and audience targeting and exposure status for each web experiment on the page, and **Preview Variant** lets you force a variant for your own session.

## Troubleshooting

### The original headline flashes before the variant

The script is loading late. It must be a plain `<script src>` at the top of `<head>` in `src/app.html`, right after `<meta charset>` and above `%sveltekit.head%`, not added from `<svelte:head>`, `onMount`, `src/hooks.client.ts`, or a tag manager, and without `async` or `defer`.

### The script request fails with `/script/.experiment.js` in the URL

`%sveltekit.env.PUBLIC_AMPLITUDE_API_KEY%` resolved to an empty string. The variable is missing where the server runs, or its name doesn't start with `PUBLIC_`. Restart the dev server after editing `.env`. In production, set the variable in the runtime environment as well as the build environment, and rebuild prerendered pages.

### The variant flashes in, then the original comes back on page load

Svelte rewrote the element during hydration. Either the text comes from an expression, or a **Move** or remove change altered the structure Svelte expected; check the console for `hydration_mismatch` or other errors. Test static markup, or move the test to a feature flag.

### The variant applies on the first load but not after client-side navigation

The element renders after the URL change: SvelteKit renders the new route just after it updates the URL, and `{#await}` blocks, `{#if}` blocks on client state, and transitions render later still. First confirm the selector matches the rendered element: run `document.querySelector('[data-exp="home-headline"]')` in the DevTools console. If it does, switch that Page's trigger to **When Element Appears** and select the element by its `data-exp` attribute.

### The variant worked yesterday and stopped after a deploy

The selector included a `svelte-` hashed class that changed when the component's styles changed. Edit the change in the Visual Editor and set its selector to the `data-exp` attribute.

### Anonymous visitors see a different variant on every visit

Something calls `amplitude.reset()` while the user is signed out, which issues a new device ID. Reset only in the sign-out form's `use:enhance` callback, and keep the early return in the layout `$effect`.

### Page views or sessions doubled after adding the script

You loaded the Unified Script, `API_KEY.js`, next to the npm SDK. Replace it with `API_KEY.experiment.js`.

## Full code reference

Every file from this tutorial in one block, the version to hand to an agent. It uses the synchronous tag; swap in the async alternative from step 2 only if you must. For the optional `webExperiment.plugin()` registration, use the `src/hooks.client.ts` from step 3 instead. Add the step 5 headers only if you already send a CSP or COOP. 4 files.

4 files, 65 lines

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

// -- src/app.html (add the <script> line; keep the rest of your app.html) --
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <!-- Amplitude Web Experiment. Keep it first after the charset, and synchronous.
         EU projects: https://cdn.eu.amplitude.com/script/ -->
    <script src="https://cdn.amplitude.com/script/%sveltekit.env.PUBLIC_AMPLITUDE_API_KEY%.experiment.js"></script>
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    %sveltekit.head%
  </head>
  <body data-sveltekit-preload-data="hover">
    <div style="display: contents">%sveltekit.body%</div>
  </body>
</html>

// -- 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: {
      // 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]'],
      },
  });
};

// -- src/routes/+page.svelte --
<section data-exp="home-hero">
  <h1 data-exp="home-headline">Plan your next project in minutes</h1>
  <p data-exp="home-subhead">Boards, roadmaps, and templates in one place.</p>
  <a href="/projects/new" data-exp="home-cta">Create a project</a>
</section>

// -- src/routes/+layout.server.ts, src/routes/+layout.svelte, src/lib/components/SignOutButton.svelte, src/routes/projects/new/+page.svelte --
// Unchanged from /build/sveltekit-product-analytics (keep any changes other SvelteKit pages made). src/hooks.server.ts: your existing hook, plus the step 5 headers if you send a CSP or COOP.
```

## 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
This app should already have the setup from
https://amplitude.com/build/sveltekit-product-analytics. If src/hooks.client.ts
doesn't initialize Amplitude yet, follow that page first.

Then add Amplitude Web Experiment by following
https://amplitude.com/build/sveltekit-web-experiment exactly. Add
<script src="https://cdn.amplitude.com/script/%sveltekit.env.PUBLIC_AMPLITUDE_API_KEY%.experiment.js"></script>
as the first tag after <meta charset> inside <head> in src/app.html, above
%sveltekit.head%, synchronous, with no async or defer. Keep the rest of
app.html as it is. Do not load the script from <svelte:head>, onMount, a
$effect, src/hooks.client.ts, or a tag manager. Do not add the Unified Script
(API_KEY.js) and do not install another npm package for Web Experiment;
@amplitude/unified doesn't include it. Never put %sveltekit.nonce% in app.html.

Keep analytics in the existing init export of src/hooks.client.ts and don't
call initAll a second time. Keep any sessionReplay.privacyConfig and debugMode
already there. If src/hooks.client.ts wraps initAll in setAmplitudeReady(...)
(from /build/sveltekit-feature-flags or /build/sveltekit-guides-surveys), keep
that wrapper, its import and the setAmplitudeReady(null) line exactly.

Don't rewrite src/routes/+layout.svelte, src/routes/+layout.server.ts or the
sign-out button: keep the layout's identity $effect as it is, including its
lastIdentity check and any code other SvelteKit pages added, and keep any
existing lines in the sign-out callback. Make sure no amplitude.reset() call
runs when the user is null; call reset() only in the sign-out use:enhance
callback.

In src/routes/+page.svelte, add a hero section with data-exp="home-hero", an
h1 with data-exp="home-headline", a p with data-exp="home-subhead", and a link
to /projects/new with data-exp="home-cta", all with static text (no {}
expressions). If the app has no src/routes/projects/new/+page.svelte, create
it from the product analytics page. Add data-exp attributes to any other
elements I name, on plain HTML elements with static text, never relying on
svelte- hashed class names.

If the app sends Content-Security-Policy or Cross-Origin-Opener-Policy headers
from its own code, edit the existing handle in src/hooks.server.ts after the
auth code: for text/html responses only, add *.amplitude.com and
'unsafe-inline' to the existing script-src directive, and to script-src-elem
too if the policy has one (add a script-src first if the policy has only
default-src), without replacing the rest of the policy, and set COOP to
unsafe-none. If the app sets kit.csp in svelte.config.js, add
'*.amplitude.com' and 'unsafe-inline' to its script-src array there instead of
rewriting the header, and tell me that unsafe-inline weakens the policy.

Tell me PUBLIC_AMPLITUDE_API_KEY must be set in the runtime environment as
well as at build time. Don't hardcode the key. Report every file you changed.
```

## What this unlocks

- [Session replay](https://amplitude.com/session-replay), on at the sample rate from the product analytics page, shows sessions where visitors saw the treatment variant and `Project Created` never fired, so you can watch why.
- [Heatmaps](https://amplitude.com/heatmaps) are built from session replays, so they compare where people click on the home page hero in each variant.
- [Feature Experiment](https://amplitude.com/amplitude-experiment), already in `@amplitude/unified`, puts the winning headline in `src/routes/+page.svelte` behind a flag when you want a gradual rollout, since the docs recommend moving a winner into code rather than leaving the web experiment at 100%.
- [Guides and surveys](https://amplitude.com/guides-and-surveys) can ask visitors who saw the treatment what they expected after clicking `home-cta`.

One integration; everything else reads from the same events.

## Next steps

[Web frameworksHow to add feature flags and experiments to a SvelteKit appTest changes that depend on data or state, and evaluate on the server so the first paint already has the right variant.](/build/sveltekit-feature-flags)[Web frameworksHow to add session replay to a SvelteKit appWatch the sessions behind each variant's numbers.](/build/sveltekit-session-replay)[Web frameworksHow to add heatmaps to a SvelteKit appCompare where people click on the hero in control and treatment.](/build/sveltekit-heatmaps)

Reference docs

[Implement Web Experiment →](https://amplitude.com/docs/web-experiment/implementation)[Set up a web experiment →](https://amplitude.com/docs/web-experiment/set-up-a-web-experiment)[Web Experiment Pages and triggers →](https://amplitude.com/docs/web-experiment/pages)[Web Experiment actions →](https://amplitude.com/docs/web-experiment/actions)[Web Experiment event tracking →](https://amplitude.com/docs/web-experiment/tracking)[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-web-experiment.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)
