How to add session replay to a SvelteKit app

Turn on Amplitude session replay in a SvelteKit app, record every session in development and a small share in production, mask sensitive components and whole routes, and open your own test replay. About 15 minutes, all copy-paste.

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

Chris Tam · Last updated 13 October 2026

Bug reports rarely include the state that caused them. Session replay records what each user of your SvelteKit app saw and did, so "the button didn't work" becomes a session you can watch.

In this tutorial, you'll turn on session replay in a SvelteKit app, set a sample rate per environment, mask sensitive components and routes, and confirm a replay lands. It builds on How to add product analytics to a SvelteKit app.

What you'll build

A SvelteKit app that records every session in development and 1% of sessions in production, masks text inputs by default, masks anything wrapped in an AmpMask component and every page under /billing, replaces blocked elements with a placeholder, and tags the session's events with [Amplitude] Session Replay ID. By the end you'll play back your own test session from your event stream in Amplitude.

Prerequisites

  • A SvelteKit 2.10 or later app with Svelte 5 and TypeScript, for example one created with npx sv create. The init export in src/hooks.client.ts arrived in SvelteKit 2.10.
  • An Amplitude project API key. In Amplitude, go to Settings > Projects > your project > General.
  • To change sampling or masking in Amplitude's settings, an Admin or Manager role. The code on this page doesn't need it.

No Amplitude account yet? Session replay for web is available on all plans, so a free account covers this tutorial. Sign up for free to get your project API key.

1. Install the SDK

The Quickstart recommends @amplitude/unified for browser apps installed from npm. It bundles analytics, session replay, Feature Experiment, and guides and surveys behind one initAll call, and session replay records once sessionReplay.sampleRate is above 0. If you followed the SvelteKit product analytics page, it's already installed; skip to step 2.

Terminal
npm install @amplitude/unified

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

.env
PUBLIC_AMPLITUDE_API_KEY=your-project-api-key

Already on @amplitude/analytics-browser to keep bundle size down? The session replay plugin takes the same options as the sessionReplay block below. Add it to your existing init rather than installing a second SDK.

2. Set a sample rate per environment

Initialize once, in the init export of the client hook, and choose the sample rate from SvelteKit's dev flag. This file replaces src/hooks.client.ts from the SvelteKit product analytics page. Keep any options you added on other SvelteKit pages, such as pageViews: { trackHistoryChanges: 'pathOnly' } from the web analytics page, and change only the sessionReplay block. If you added the setAmplitudeReady(...) wrapper on the SvelteKit feature flags page or the SvelteKit guides and surveys page, keep it around initAll.

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,
        // Tracks [Amplitude] Start Session and End Session events.
        sessions: true,
        formInteractions: true,
        fileDownloads: true,
        elementInteractions: true,
      },
    },
    sessionReplay: {
      // Fraction of sessions to record: every session in `vite dev`, 1% in a production build.
      sampleRate: dev ? 1 : 0.01,
      // Ignores the focus check so replays record with DevTools open. Setup only.
      debugMode: dev,
      // Selectors for the data attributes in steps 3 and 4.
      privacyConfig: {
        maskSelector: ['[data-amp-mask]'],
        blockSelector: ['[data-amp-block]'],
        unmaskSelector: ['[data-amp-unmask]'],
      },
    },
  });
};

Why. Session replay takes a full snapshot of the page when it initializes, and nothing that happens before that point appears in the replay. SvelteKit calls the client hook's init once, in the browser, before the server-rendered page hydrates, so the masking attributes are already in the DOM, straight from the server HTML, when the first snapshot is taken. It also matters for matching: initializing the SDK more than once can leave the replay and the session's events with different device or session IDs, and a replay that doesn't match its events is one you can't open from the event stream. An initAll in onMount inside a page component runs again on every visit to that page, and one in the root layout's onMount starts after the first paint.

Later pages in this hub show src/hooks.client.ts again. If you copy a version that lacks privacyConfig, keep this block.

The plugin's own default sample rate is 0, so nothing records until you set one. Amplitude recommends starting production low, around 0.01, and raising it over a few days; capture stops for the month once your org's quota runs out. To stop recording, set the rate to 0. Session Replay Settings in Amplitude can also set a project's rate remotely, so check Settings > Organizational Settings > Session Replay Settings when the rate you see doesn't match your code. For every option, see the session replay plugin configuration; the Unified SDK accepts all of them inside sessionReplay except serverZone and instanceName, which go at the top level of the initAll options.

Unified also initializes guides and surveys by default. If you won't use them, the Unified SDK docs describe an engagement: { skip: true } option that stops that SDK from loading.

3. Mask sensitive elements in Svelte components

Session replay masks every text input by default: typed characters arrive as asterisks. The project's default masking level, Medium, masks all form fields and text inputs and captures other text as-is. Everything else is your call, element by element.

The SDK recognizes three classes out of the box, amp-mask, amp-block, and amp-unmask, and they work on any element in Svelte markup. The data attributes registered in step 2 keep privacy separate from styling, and they're what the wrapper component below sets. Put one small component in charge of masking, so every page says it the same way. In Svelte 5, the wrapped content arrives as the children snippet:

src/lib/components/AmpMask.svelte
<script lang="ts">
  import type { Snippet } from 'svelte';
  import type { HTMLAttributes } from 'svelte/elements';

  type Props = HTMLAttributes<HTMLElement> & {
    // Replace the content with a same-size placeholder instead of asterisks.
    block?: boolean;
    // Use 'span' inside inline content. Use a non-void element, never 'img' or 'input'.
    tag?: string;
    children: Snippet;
  };

  let { block = false, tag = 'div', children, ...rest }: Props = $props();
</script>

<svelte:element
  this={tag}
  {...rest}
  data-amp-mask={block ? undefined : ''}
  data-amp-block={block ? '' : undefined}
>
  {@render children()}
</svelte:element>

Use it in any page or component, next to plain data attributes where a wrapper would be overkill:

src/routes/team/+page.svelte
<script lang="ts">
  import AmpMask from '$lib/components/AmpMask.svelte';

  type Member = { id: string; name: string; email: string; avatarUrl: string };

  // Replace with data from your load function.
  const members: Member[] = [
    { id: '1', name: 'Test User', email: 'test@example.com', avatarUrl: 'https://placehold.co/32' },
  ];
</script>

<main>
  <h1>Team</h1>

  <!-- Inputs are masked by default. This one is safe to watch. -->
  <input data-amp-unmask name="filter" placeholder="Filter by name" />

  <ul>
    {#each members as member (member.id)}
      <li class="row">
        <!-- Replaced by a same-size placeholder -->
        <AmpMask block tag="span">
          <img src={member.avatarUrl} alt="" />
        </AmpMask>
        <span>{member.name}</span>
        <!-- Captured as asterisks -->
        <AmpMask tag="span">{member.email}</AmpMask>
      </li>
    {/each}
  </ul>
</main>

<style>
  .row {
    display: flex;
    gap: 0.5rem;
  }
</style>

Svelte renders a bare data-amp-unmask and the component's '' value as data-amp-unmask="" and data-amp-mask="", which the attribute selectors match. An undefined value leaves the attribute off. Both render on the server, so the attributes are in the HTML before the SDK starts.

Why. Svelte components don't forward attributes to an element on their own. Writing data-amp-mask on a component tag, such as <MemberEmail data-amp-mask />, passes a prop that the component ignores unless it spreads its props onto an element, and nothing in the replay tells you. The wrapper puts the attribute on an element you can see in the markup, and its ...rest spread lets you pass id, role, or a class through as well (a class styled in the parent's <style> block needs :global() to reach it). Masking also follows the DOM, not the component tree: a dialog, popover, or toast that a component library renders into document.body through a portal leaves its masked parent, so wrap the content inside the portal itself. And masking covers text and form inputs, not HTML attributes. alt, title, placeholder, aria-label, value, and data-* attributes stay visible in replays even inside a masked element, so a tooltip written as title={member.email} leaks the address no matter what you mask.

Don't target the class names Svelte generates for scoped styles, such as svelte-1x2y3z, as masking selectors, in code or in Session Replay Settings. They're hashes produced at build time and change when the component's styles change, at which point the rule stops matching without any error.

When the masking rules in Session Replay Settings and the SDK disagree about an element, the settings page wins. You can change the project's level (Light, Medium, Conservative) or add selector overrides there, per project; see Manage privacy settings for session replay.

4. Mask whole routes with a nested layout

Some pages shouldn't show any text in a replay, such as billing, invoices, or a patient record. Masking each element there is how one gets missed. In SvelteKit, a +layout.svelte inside a route folder wraps every page beneath it, so one file covers a whole section:

src/routes/billing/+layout.svelte
<script lang="ts">
  import AmpMask from '$lib/components/AmpMask.svelte';

  let { children } = $props();
</script>

<!-- Everything rendered under /billing is masked, including pages added later. -->
<AmpMask>
  {@render children()}
</AmpMask>

Add two pages for the section, so there's text to check in the replay. If you already have billing pages, use those instead:

src/routes/billing/+page.svelte
<h1>Billing</h1>
<p>Card ending 4242, billed to Test User</p>
src/routes/billing/invoices/+page.svelte
<h1>Invoices</h1>

There's no router file to edit; the folders are the routes. Your root src/routes/+layout.svelte with the identity $effect from the SvelteKit product analytics page stays as it is, and this layout renders inside it.

To mask several sections that don't share a URL prefix, such as /billing and /account, move them into a route group like src/routes/(masked)/ and put this layout at src/routes/(masked)/+layout.svelte. Group folders in parentheses don't change the URL.

Why. Masking an element through a selector masks all of its children, and a child inside it can't be unmasked again. That's what you want here: a developer who adds src/routes/billing/payment-methods/ next year gets masking without knowing this file exists. It also means data-amp-unmask does nothing under /billing, so put content you want visible outside the folder. SvelteKit keeps the layout mounted while someone moves between /billing and /billing/invoices, so the wrapper never disappears mid-session.

The layout masks text, not structure. Clicks, scrolling, and layout still record, so you can follow what the user did on /billing without reading it. To hide the region entirely, use <AmpMask block> in the layout instead.

The layout doesn't cover the page <title>; keep personal data out of titles under /billing, or add data-amp-mask to the <title> in that section's <svelte:head>.

MCP. Working in Cursor or Claude Code? Amplitude's MCP server includes get_session_replays, and the debug-replay skill in Amplitude's MCP marketplace plugin turns a bug report into numbered reproduction steps from the replay's interaction timeline, so you don't have to scrub through sessions by hand. Set up the MCP server.

5. Allow Amplitude in your CSP

SvelteKit sends no Content-Security-Policy unless you configure one in kit.csp in svelte.config.js. Skip this step unless you did, or your host adds the header. If you did, add Amplitude's hosts to the connect-src directive you already have:

svelte.config.js (inside kit.csp.directives; keep your other directives)
'connect-src': [
  'self',
  'https://api2.amplitude.com',
  'https://api-sr.amplitude.com',
  'https://sr-client-cfg.amplitude.com',
],

api-sr.amplitude.com receives replay data and sr-client-cfg.amplitude.com serves the session replay remote configuration. api2.amplitude.com is where the analytics SDK sends events. EU projects use api-sr.eu.amplitude.com and sr-client-cfg.eu.amplitude.com and set serverZone: 'EU' at the top level of the initAll options. Add 'worker-src': ['blob:'] only if you turn on useWebWorker. Unified also loads guides and surveys by default, which needs https://*.amplitude.com in script-src, connect-src, img-src, media-src, and style-src. Allow those, or turn guides and surveys off with engagement: { skip: true }.

Why. If remote configuration fails to load, session replay captures nothing, on purpose, so it never records without the privacy settings you defined in Amplitude. A blocked config request looks like "replay does nothing" rather than like an error, until you open the console and find a Content Security Policy message.

Verify it worked

Run npm run dev, open http://localhost:5173 in a fresh private window, visit /team, type into the filter, open /billing, then check in this order:

  1. Console and Network tab. No Content Security Policy errors in the console. In the Network tab, filter for amplitude: you should see event uploads to api2.amplitude.com returning 200, a request to sr-client-cfg.amplitude.com, and replay uploads to api-sr.amplitude.com. 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 event uploads. It shows counts, not individual events.
  3. User lookup. Go to Users > User Profiles and search your device ID (the Instrumentation Explorer Chrome extension shows it as device_id) or your user- prefixed user ID if you're signed in. Within ten seconds to a minute, the session's events, such as [Amplitude] Start Session, carry [Amplitude] Session Replay ID, and the event stream shows a Play Session button next to that session. Replays are generally ready about five minutes after the session begins.
  4. Session Replay debug panel. Install the Instrumentation Explorer Chrome extension and turn on its Session Replay debug panel on your page. It floats over the page and shows the current replay ID, the session replay SDK and plugin versions, the sample rate, and the default and effective masking settings. A sample rate of 1 in development confirms step 2 took effect, and the effective masking settings show whether Session Replay Settings overrode your code.
  5. The replay itself. Play it. On /team, the filter text is visible, emails are asterisks, and avatars are grey placeholders. On /billing, every line of text is masked while clicks and scrolling still show.

If the replay won't load, open the Ingestion Monitor from the top right of the Session Replay section of your project's Users & Sessions tab. It shows replay request statuses, including 429 throttling and 402 quota errors.

To force a fresh session while testing, delete the cookies that start with AMP_ in DevTools and reload.

Replays recorded against localhost can play back unstyled, because Amplitude can't fetch files from your machine. Check styling against a deployed preview.

Troubleshooting

No session replay requests at all

The effective sample rate is 0. Check sessionReplay.sampleRate, then the rate in Session Replay Settings; the debug panel shows the rate the SDK is using. A production build (npm run build, then npm run preview) has dev set to false and records only about one session in a hundred at 0.01, so test with npm run dev or raise the rate temporarily.

Replays record in production but not while you debug

Session replay only captures the tab that has focus, and an open DevTools panel can take it. Keep debugMode: dev from step 2, which ignores the focus check in development.

Events arrive but the session has no Play Session button

No event in that session carries [Amplitude] Session Replay ID. The usual cause is a second initAll or init call, for example in an onMount in a layout or page, so the replay and the events disagree on device or session ID. Search the codebase and keep the one in src/hooks.client.ts.

Masked text shows up in a replay

Check four things in order. The text sits in an attribute such as title or aria-label, which masking never covers. The data-amp-mask sits on a component tag and never reached the DOM; inspect the element, then wrap the content in AmpMask. The text renders through a portal into document.body, outside the masked wrapper. Or the element was unmasked in Session Replay Settings, which overrides the SDK; the debug panel's effective masking settings show it.

Old replays lose their styling after a deploy

SvelteKit writes CSS, including each component's scoped styles, to hashed file names under _app/immutable/, and a deploy that replaces the build output removes the old ones. Keep previous build assets available on your host, allow app.amplitude.com in your asset server's CORS policy, and add crossorigin="anonymous" to any external <link rel="stylesheet">.

Requests fail with ERR_BLOCKED_BY_CLIENT

An ad blocker is dropping them. Session replay isn't compatible with ad blocking software, so test in a clean profile. For production traffic, route requests through your own domain and point trackServerUrl and configServerUrl at it, and set serverUrl in the analytics block for events, plus remoteConfig.serverUrl for remote configuration, as on the web analytics page.

No requests to Amplitude at all, or the build fails with "PUBLIC_AMPLITUDE_API_KEY is not exported"

The key is empty or missing, so the warning from step 2 is in the console, or $env/static/public can't find it at build time. The variable must start with PUBLIC_, and the dev server only reads .env at startup, so restart it. The value is baked in at build time; set it in your build environment and rebuild.

Full code reference

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

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

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

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

  void amplitude.initAll(PUBLIC_AMPLITUDE_API_KEY, {
    analytics: {
      autocapture: {
        attribution: true,
        pageViews: true,
        sessions: true,
        formInteractions: true,
        fileDownloads: true,
        elementInteractions: true,
      },
    },
    sessionReplay: {
      sampleRate: dev ? 1 : 0.01,
      debugMode: dev,
      privacyConfig: {
        maskSelector: ['[data-amp-mask]'],
        blockSelector: ['[data-amp-block]'],
        unmaskSelector: ['[data-amp-unmask]'],
      },
    },
  });
};

// -- src/lib/components/AmpMask.svelte --
<script lang="ts">
  import type { Snippet } from 'svelte';
  import type { HTMLAttributes } from 'svelte/elements';

  type Props = HTMLAttributes<HTMLElement> & {
    block?: boolean;
    tag?: string;
    children: Snippet;
  };

  let { block = false, tag = 'div', children, ...rest }: Props = $props();
</script>

<svelte:element
  this={tag}
  {...rest}
  data-amp-mask={block ? undefined : ''}
  data-amp-block={block ? '' : undefined}
>
  {@render children()}
</svelte:element>

// -- src/routes/team/+page.svelte --
<script lang="ts">
  import AmpMask from '$lib/components/AmpMask.svelte';

  type Member = { id: string; name: string; email: string; avatarUrl: string };

  // Replace with data from your load function.
  const members: Member[] = [
    { id: '1', name: 'Test User', email: 'test@example.com', avatarUrl: 'https://placehold.co/32' },
  ];
</script>

<main>
  <h1>Team</h1>
  <input data-amp-unmask name="filter" placeholder="Filter by name" />
  <ul>
    {#each members as member (member.id)}
      <li class="row">
        <AmpMask block tag="span">
          <img src={member.avatarUrl} alt="" />
        </AmpMask>
        <span>{member.name}</span>
        <AmpMask tag="span">{member.email}</AmpMask>
      </li>
    {/each}
  </ul>
</main>

<style>
  .row {
    display: flex;
    gap: 0.5rem;
  }
</style>

// -- src/routes/billing/+layout.svelte --
<script lang="ts">
  import AmpMask from '$lib/components/AmpMask.svelte';

  let { children } = $props();
</script>

<AmpMask>
  {@render children()}
</AmpMask>

// -- src/routes/billing/+page.svelte --
<h1>Billing</h1>
<p>Card ending 4242, billed to Test User</p>

// -- src/routes/billing/invoices/+page.svelte --
<h1>Invoices</h1>

// -- src/routes/+layout.svelte, src/routes/+layout.server.ts, src/lib/components/SignOutButton.svelte --
// Your existing files from the SvelteKit product analytics page. Nothing in them changes.

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
Complete https://amplitude.com/build/sveltekit-product-analytics first, or
confirm this SvelteKit 2 + Svelte 5 app already has its setup. Then add
Amplitude session replay by following
https://amplitude.com/build/sveltekit-session-replay exactly. If the app
already uses @amplitude/analytics-browser, add
@amplitude/plugin-session-replay-browser to the existing init instead of
installing a second SDK, pass the same options to
sessionReplayPlugin({...}), and call amplitude.add() with it before
amplitude.init(). Otherwise use @amplitude/unified. Call initAll once
inside the init export of src/hooks.client.ts, reading
PUBLIC_AMPLITUDE_API_KEY from $env/static/public, and keep the guard that
warns and returns when the key is empty. Keep the existing analytics
block, including any pageViews options another page added; change only
sessionReplay. If there is no analytics block yet, use the page's
autocapture block, with elementInteractions on. If src/hooks.client.ts
wraps initAll in setAmplitudeReady(...), from the feature flags or guides
and surveys page, keep that wrapper around initAll. Never initialize at
the top of a component script, in a load function, or in onMount. Set
sessionReplay.sampleRate to dev ? 1 : 0.01 with dev from
$app/environment, set debugMode to dev, and register [data-amp-mask],
[data-amp-block], and [data-amp-unmask] in sessionReplay.privacyConfig.
Create src/lib/components/AmpMask.svelte, a Svelte 5 wrapper with block
and tag props that renders data-amp-mask (or data-amp-block when block is
set) on a svelte:element around {@render children()}. Wrap elements that
render emails, billing names, or account data in it, and wrap images of
people or documents in <AmpMask block>. Put billing and any other
sensitive section under a +layout.svelte that renders
<AmpMask>{@render children()}</AmpMask>, using a route group if the
sections don't share a URL prefix. If the app has no /team or /billing
pages, create the page's demo files so the Verify steps run:
src/routes/team/+page.svelte (a filter input with data-amp-unmask, each
member's avatar in <AmpMask block tag="span"> and email in
<AmpMask tag="span">), src/routes/billing/+layout.svelte,
src/routes/billing/+page.svelte, and
src/routes/billing/invoices/+page.svelte. Keep the existing root layout
and its identity $effect. Don't put masking attributes on component tags,
wrap portal content separately, and never use Svelte's generated
svelte-xxxx class names as masking selectors. Flag any title, aria-label,
alt, or placeholder binding that contains personal data. Don't hardcode
the key. Report every file you changed and any kit.csp you found.

What this unlocks

  • Session replay sits on the same events as your charts: open a funnel step that ends at /billing and watch the sessions where users dropped, text masked.
  • Heatmaps are built from session replays, so once heatmap capture is on for the project in Session Replay Settings, clicks and scrolls on /team aggregate without new code.
  • Feature Experiment, bundled in @amplitude/unified, lets you filter replays by variant and see how people use a new version of /team.
  • Guides and surveys, also bundled, can ask the users whose replays show a stall on /billing what they were looking for.

One integration; everything else reads from the same events.

Next steps

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

Ship the instrumentation, then read the data

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