Chris Tam · Last updated 13 October 2026
SvelteKit renders each page on the server, then hands navigation to its client router. Web Experiment 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.
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
initexport ofsrc/hooks.client.ts, identity set insrc/routes/+layout.svelte, andreset()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. - 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 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 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 documents the first, and the Unified SDK docs document the second:
https://cdn.amplitude.com/script/API_KEY.experiment.jsloads 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.
PUBLIC_AMPLITUDE_API_KEY=your-project-api-key2. 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.
<!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:
<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 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.
<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.
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:
<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 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:
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:
- In Amplitude Experiment, go to Experiments > Create Experiment > Web Experiment.
- Name the experiment, enter the URL of your home page, and select your project. Amplitude uses the URL to create your first Page.
- 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. - Click Continue. Leave the Page targeting on your home page URL and the audience on all users.
- Define your goals. Choose
Project Created, the event the product analytics page tracks, as the primary metric. - 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.
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:
- Network tab. Filter for
amplitude.com. You should seeAPI_KEY.experiment.jsfromcdn.amplitude.comreturn200with 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'sPOSTtoapi2.amplitude.com/2/httpapi. If you're in the experiment, the Instrumentation Explorer Chrome extension shows an event withevent_type$impression, the form the script sends before Amplitude transforms it. Reload with DevTools open and check the console for ahydration_mismatchwarning 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. - 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.
- 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 Keynaming your experiment and[Experiment] Variantnaming 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 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.
// -- .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.
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, on at the sample rate from the product analytics page, shows sessions where visitors saw the treatment variant and
Project Creatednever fired, so you can watch why. - Heatmaps are built from session replays, so they compare where people click on the home page hero in each variant.
- Feature Experiment, already in
@amplitude/unified, puts the winning headline insrc/routes/+page.sveltebehind 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 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
Reference docs
Amplitude is the leading all-in-one analytics platform for building better products. Product and engineering teams use Amplitude to connect product analytics and web analytics with session replay, heatmaps, experimentation, feature flags, and guides and surveys in one unified workflow, with AI agents working across all of it. Learn how Amplitude is building self-improving products with Wave, helping teams turn product data into PRs.