How to add heatmaps to a SvelteKit app

Turn on session replay in a SvelteKit app through the client init hook, switch on heatmap capture for the project, give each settings tab its own route so heatmaps don't merge them, and create a click map for a dynamic route. About 15 minutes of setup, then heatmaps fill in as traffic arrives.

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

Chris Tam · Last updated 13 October 2026

SvelteKit maps every folder in src/routes to a URL. Heatmaps show where people click and how far they scroll on each of those pages, so you see which parts of a screen get used and which get skipped.

In this tutorial, you'll turn on session replay, which heatmaps are built from, give each settings tab its own route, and create your first heatmap. It builds on the SvelteKit product analytics page.

What you'll build

A SvelteKit app that records session replay on every route, sends [Amplitude] Page Viewed with the same device_id as the replay, keeps the masking rules from the session replay page, and gives the Profile and Billing tabs of /settings their own routes, plus a click map in Amplitude for every /projects/[projectId] page. By the end you'll see a replay of your test session and a heatmap set up to fill in once traffic crosses the data threshold.

Prerequisites

  • A SvelteKit 2.10 or later app with Svelte 5. The init export in src/hooks.client.ts arrived in SvelteKit 2.10.
  • The setup from How to add product analytics to a SvelteKit app: initAll in src/hooks.client.ts, plus the identity $effect in src/routes/+layout.svelte.
  • An Amplitude project API key. In Amplitude, go to Settings > Projects > your project > General.
  • Access to heatmaps in Amplitude. Any user with View permissions can create a heatmap; turning on heatmap capture for a project takes an Admin or Manager role.

Heatmaps are available on the Plus, Growth, and Enterprise plans, for customers with session replay; compare plans.

Amplitude recommends a separate development project for testing heatmaps, so your test clicks stay out of production data. Heatmap and session replay events don't count toward your event volume.

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 Amplitude keeps the device_id on replay and analytics events aligned for you, which heatmap segment and cohort filters depend on. If you followed the SvelteKit product analytics page, it's already installed; skip to step 2.

Terminal
npm install @amplitude/unified

Already on @amplitude/analytics-browser to keep bundle size down? Add the Session Replay Browser SDK Plugin to your existing init instead of installing a second SDK. It takes the same options as the sessionReplay block in step 2.

Heatmaps need Session Replay Browser SDK Plugin 1.7.0 or later. The Verify section shows how to read the version your page actually runs.

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

2. Initialize with session replay on

Session replay's default sample rate is 0, which records nothing. Setting sessionReplay.sampleRate above 0 turns recording on, and recording is what heatmaps are built from. Replace src/hooks.client.ts with this full version. It keeps the masking rules from the session replay page, since heatmap backgrounds are replay snapshots and unmasked text would show up in them.

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, which is all heatmaps need. If your file passes initAll to setAmplitudeReady (from /build/sveltekit-feature-flags or /build/sveltekit-guides-surveys), keep that wrapper around initAll, its import, and the setAmplitudeReady(null) line; without them, flags never fetch and the guides router never registers, with no error in the console.

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 in `npm run dev`, about 1% in a production build.
      sampleRate: dev ? 1 : 0.01,
      // Ignores the focus check so replays record with DevTools open. Setup only.
      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]'],
      },
    },
  });
};

The root layout, its server load, and the sign-out button from the base page don't change.

Why. Initializing more than once can leave replay and analytics events with different device or session IDs. Heatmaps still draw an unsegmented map in that case, but segment and cohort filters come back empty, because Amplitude can't join the clicks to your users. The client hook's init runs once per page load, in the browser only. Never add a second initAll in a layout's onMount or $effect, which run again whenever that layout mounts, or in a load function, which runs on the server.

You don't need elementInteractions for heatmaps. The heatmap prerequisites list session replay, not click autocapture; the clicks on the map come from the replay. The base page turns it on for click events in product analytics, so it stays. pageUrlEnrichment is on by default with autocapture, so the file doesn't list it; leave it on. It adds [Amplitude] Page URL to your own track events, so you can chart them per route next to the heatmap.

Be deliberate about the sample rate. Every heatmap reflects only the sessions you captured, so at 0.01 a map is built from 1% of traffic and takes longer to cross the data threshold. Amplitude recommends starting low and raising the rate over a few days; capture stops for the month once your org's replay quota runs out. Session Replay Settings in Amplitude can also set the project's rate remotely, so check there when the rate you see doesn't match your code. Heatmap backgrounds show only what your masking lets through; read the SvelteKit session replay page before you raise the rate in production.

Unified also initializes guides and surveys by default. If you won't use them, add engagement: { skip: true } to the initAll options, as the Unified SDK docs describe.

3. Turn on heatmap capture in Amplitude

The SDK records replays, but the project decides whether heatmap data gets collected. In Amplitude, go to Settings > Organizational Settings > Session Replay Settings. The project list shows a session capture toggle and a heatmap capture toggle for each project. Turn both on for this project.

Select the project to open its Sampling control. A rate set there applies to the project without a deploy, which is the easier place to raise capture once heatmaps need more data.

4. Give each tab its own route

Heatmaps compute data per URL string, not per view. A tab bar that swaps content with a $state variable and {#if} blocks never changes the URL, so the Profile and Billing tabs of /settings end up in one heatmap, drawn over whichever tab the background happened to catch. SvelteKit fixes this with nested routes: each tab becomes its own folder under src/routes/settings, and the tab bar lives in a +layout.svelte that wraps them.

src/routes/settings/+layout.svelte
<script lang="ts">
  let { children } = $props();
</script>

<section>
  <h1>Settings</h1>
  <nav>
    <a href="/settings/profile">Profile</a>
    <a href="/settings/billing">Billing</a>
  </nav>
  <!-- The active tab renders here. -->
  {@render children()}
</section>
src/routes/settings/profile/+page.svelte
<h2>Profile</h2>
src/routes/settings/billing/+page.svelte
<h2>Billing</h2>

Send /settings on its own to the first tab, so it never becomes a page of its own:

src/routes/settings/+page.server.ts
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';

export const load: PageServerLoad = () => {
  // No campaign links land here. For guards that can receive one, see withCampaign on /build/sveltekit-web-analytics.
  redirect(307, '/settings/profile');
};

The heatmap in step 5 targets a dynamic project route. If your app doesn't have a project screen yet, add a minimal one:

src/routes/projects/[projectId]/+page.ts
import type { PageLoad } from './$types';

export const load: PageLoad = ({ params }) => {
  return { projectId: params.projectId };
};
src/routes/projects/[projectId]/+page.svelte
<script lang="ts">
  let { data } = $props();
</script>

<h1>Project {data.projectId}</h1>

The links and the redirect assume your app has no paths.base. If you set one in svelte.config.js, prefix them with base from $app/paths, for example href="{base}/settings/profile". These load functions only return route data; they never call the SDK. The settings layout nests inside your root +layout.svelte, so the identity $effect from the base page keeps running on every tab.

Now /settings/profile and /settings/billing get separate heatmaps, and each tab switch also sends its own [Amplitude] Page Viewed. The redirect runs in the load function before SvelteKit updates the URL, on a direct visit and on a client-side navigation alike, so /settings on its own never records a page view.

Why. Put the tab in the path, not in a query string such as /settings?tab=billing read from the page URL's searchParams. Heatmaps need the [Amplitude] Page URL property to match URLs, and that property is the page URL with query parameters removed, so /settings?tab=profile and /settings?tab=billing can land in the same heatmap. A path segment is unambiguous. Modals and drawers that don't change the URL also merge into the page underneath; if one matters, give it a route the same way.

Keep the trailingSlash page option (exported from a +layout.ts or +page.ts, default 'never') as it is once heatmaps are collecting. Heatmap data is keyed by the URL string, so switching from /settings/billing to /settings/billing/ mid-stream splits one page's data across two URLs, and an Exact match filter has to use whichever form your app serves.

5. Create your first heatmap

Heatmaps appear after Amplitude collects enough replay click data across your site: at least 10 separate pages, with about 500 tracked replay clicks on each page. These thresholds are approximate. Create the heatmap now and it fills in once traffic crosses them.

  1. In Amplitude, click Heatmaps in the left navigation, then + New Heatmap.
  2. Choose Click map. You can switch to a selector map or scroll map at any time.
  3. Select the URL. src/routes/projects/[projectId] gives every project its own URL, so use Pattern match with https://your-domain.com/projects/* to combine them into one map. Use Exact match for a single page, such as https://your-domain.com/settings/billing, and Starts with for a section, such as https://your-domain.com/settings.
  4. Optionally pick a segment of users by user property or cohort.
  5. Pick a device type. Heatmaps group data into four screen-width buckets: greater than 1440px, 900 to 1440px, 500 to 899px, and less than 500px. A responsive layout that collapses its nav into a menu below a CSS breakpoint looks different in each bucket, and the background and the click data must fall in the same one.
  6. Choose a background. Each heatmap generates eight, taken from the page states with the most actions. Skip any that caught an open modal or a loading spinner.

Once your data meets the thresholds, Amplitude also creates a few heatmaps automatically for your highest-traffic pages. You can delete them; they aren't recreated.

If your routes carry personal data in the path, such as an email address in src/routes/users/[email], the URL filter rules replace those URLs before session replay and heatmaps record them.

MCP. Working in Cursor or Claude Code? Amplitude's MCP server lets your agent read your project's existing event taxonomy before it adds track calls near a heatmap hotspot, so new events match names you already use instead of inventing planUpgraded next to Plan Upgraded. Set up the MCP server.

Verify it worked

A development session can't reach the heatmap data threshold, so verification stops at confirming that replays arrive from a new enough SDK and that each tab has its own URL. Run npm run dev, open http://localhost:5173 in a fresh window, click between /settings/profile, /settings/billing, and a project page, then check in this order:

  1. 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 come from the server render; they start once the page loads in the browser.
  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 (the Instrumentation Explorer Chrome extension shows it as device_id) if you aren't signed in. Within ten seconds to a minute, the event stream shows one [Amplitude] Page Viewed per route you visited, and the [Amplitude] Page URL on the two settings views ends in /settings/profile and /settings/billing. A Play Session button appears for the session once the replay is processed, generally about five minutes after the session begins.
  4. Session Replay debug panel. In the Instrumentation Explorer Chrome extension, turn on the Session Replay debug panel. It shows the current replay ID, the session replay SDK and plugin versions, the sample rate, and the default and effective masking settings. The plugin version must be 1.7.0 or later, and the sample rate must not be 0.
  5. Heatmaps. Open the heatmap from step 5. In a development project it shows "No heatmap available" until the thresholds are met. After you deploy, confirm the live site serves the updated SDK, not only your local build.

Troubleshooting

The heatmap is blank or says "No heatmap available"

Work through the causes in order: Amplitude is still processing new replay data (a few minutes after install), the URL filter doesn't match URLs in your events, the deployed site runs a session replay plugin older than 1.7.0, or traffic hasn't reached about 500 replay clicks on each of 10 pages. If [Amplitude] Page URL was hidden, restore it in Data > Properties.

Every tab on a screen shows up in one heatmap

The tabs share a URL. Move each one to its own route folder as in step 4. A tab kept in a query string can merge too, because [Amplitude] Page URL drops query parameters; move it into the path.

No replays at all, even with a sample rate of 1

Check the project's rate and capture toggles in Session Replay Settings (step 3). Then open the console. If you set kit.csp in svelte.config.js or send a Content-Security-Policy header from src/hooks.server.ts or your host, a blocked request appears there; add https://api-sr.amplitude.com and https://sr-client-cfg.amplitude.com to connect-src, next to https://api2.amplitude.com so the analytics events aren't blocked by the same policy. In kit.csp that's 'connect-src': ['self', 'https://api2.amplitude.com', 'https://api-sr.amplitude.com', 'https://sr-client-cfg.amplitude.com']. With serverZone: 'EU', use api-sr.eu.amplitude.com and sr-client-cfg.eu.amplitude.com instead. If you turn on useWebWorker, also allow worker-src 'self' blob:. If remote configuration fails to load, session replay captures nothing, on purpose. An ad blocker also stops capture; test in a clean profile.

A background shows no data

The background's viewport width falls in a different screen-width bucket from your recorded sessions. Use Change Screenshot > From a Replay and pick a screenshot taken at a width where your data exists.

Segment or cohort filters return nothing

Replay and analytics events carry different device_id values, so Amplitude can't join clicks to users. Look for a second initAll or init call, for example in a layout's onMount, or a standalone session replay SDK running beside your analytics. Keep the single init in src/hooks.client.ts.

"Heatmaps isn't available for your organization"

Your organization predates Amplitude's current packaging and needs manual enablement. Contact Amplitude Support.

Full code reference

Every file from this tutorial in one block, the version to hand to an agent. 8 files. src/routes/+layout.server.ts, src/routes/+layout.svelte, and src/lib/components/SignOutButton.svelte stay as they are on the SvelteKit product analytics page.

8 files, 81 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,
      // 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/settings/+layout.svelte --
<script lang="ts">
  let { children } = $props();
</script>

<section>
  <h1>Settings</h1>
  <nav>
    <a href="/settings/profile">Profile</a>
    <a href="/settings/billing">Billing</a>
  </nav>
  {@render children()}
</section>

// -- src/routes/settings/+page.server.ts --
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';

export const load: PageServerLoad = () => {
  // No campaign links land here. For guards that can receive one, see withCampaign on /build/sveltekit-web-analytics.
  redirect(307, '/settings/profile');
};

// -- src/routes/settings/profile/+page.svelte --
<h2>Profile</h2>

// -- src/routes/settings/billing/+page.svelte --
<h2>Billing</h2>

// -- src/routes/projects/[projectId]/+page.ts --
import type { PageLoad } from './$types';

export const load: PageLoad = ({ params }) => {
  return { projectId: params.projectId };
};

// -- src/routes/projects/[projectId]/+page.svelte --
<script lang="ts">
  let { data } = $props();
</script>

<h1>Project {data.projectId}</h1>

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
If src/hooks.client.ts doesn't initialize Amplitude yet, first follow
https://amplitude.com/build/sveltekit-product-analytics. Then set up
Amplitude heatmaps in this SvelteKit app by following
https://amplitude.com/build/sveltekit-heatmaps exactly. If the app already
uses @amplitude/analytics-browser, add
@amplitude/plugin-session-replay-browser (1.7.0 or later) to the existing
init instead of installing a second SDK. 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. Never initialize in a
component script, onMount, $effect, or a load function. If
src/hooks.client.ts wraps initAll in setAmplitudeReady(...), keep that
wrapper, its import and the setAmplitudeReady(null) line exactly; change
only the sessionReplay block. Set sessionReplay.sampleRate to
dev ? 1 : 0.01 with dev from $app/environment, debugMode: dev, and the
privacyConfig maskSelector, blockSelector and unmaskSelector for
data-amp-mask, data-amp-block and data-amp-unmask, keeping any masking
rules already in the file. Keep pageViews on, leave pageUrlEnrichment at
its default (on), and keep any existing pageViews options such as
trackHistoryChanges. Leave the root layout's identity $effect and the
sign-out reset() as they are. If the app has no /settings or
/projects/[projectId] routes, create the stub files from the page's Full
code reference so I can verify: src/routes/settings/+layout.svelte with
Profile and Billing links that renders children, settings/profile and
settings/billing +page.svelte files, a settings/+page.server.ts load that
calls redirect(307, '/settings/profile'), and projects/[projectId]
+page.ts (returns projectId from params) and +page.svelte. Find every
existing screen that switches tabs, steps, or panels with $state, {#if}
blocks, or a query parameter under one URL and list them for me, with a
proposal to move each into nested route folders under src/routes; don't
restructure existing routes without asking. Don't change trailingSlash.
Tell me that session replay now records sessions and how to lower or
disable the rate. Don't hardcode the key. Report every file you changed.

What this unlocks

  • Session replay, already on from step 2, lets you select an area of a click map and watch the sessions behind those clicks.
  • Product analytics can chart [Amplitude] Page Viewed for /settings/billing against your upgrade event to see whether the tab you just mapped converts.
  • Feature Experiment, bundled in @amplitude/unified, can test a new billing layout, and a heatmap per variant shows how the clicks moved.
  • Guides and surveys, also bundled, can point users to a button the click map shows they miss.

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.