SvelteKit

Install StepsKit (SvelteKit)

For SvelteKit 2. If you're on plain Svelte + Vite with no SvelteKit, skip to that section — the entrypoint is different.

Prerequisites

  • Your project API key, from the Integrations page in the dashboard.
  • SvelteKit 2 (Svelte 4 or 5 — the package doesn't care).
  • If your app enables SvelteKit's CSP config, read Content Security Policy first. It's the one thing here that can block the install outright.

1. Install the package

npm install stepskit

Call init() in your root layout, src/routes/+layout.svelte:

<!-- src/routes/+layout.svelte -->
<script>
  import stepskit from "stepskit";

  stepskit.init("YOUR_API_KEY");
</script>

<slot />

init() is SSR-safe — it's a no-op when there's no window, so it does nothing during the server render and runs on hydration. It's also idempotent, so there's no browser guard or onMount to write.

The root layout wraps every page and mounts once per full page load, never re-running on client-side navigation. That's what you want.

The package's call queue is doing real work here: the engine loads asynchronously, so an identify() call from a child route would otherwise arrive before there's anything to receive it. The package buffers those calls and replays them in order when the engine is ready.

Plain Svelte + Vite

Without SvelteKit there is no root layout. Call init() at the top of src/main.ts instead, above the Svelte imports — see the React guide for the same pattern.

Without the package

If you can't add a dependency, paste the snippet into src/app.html, just before </body>:

<script>
  (function () {
    var stepskit = window.stepskit = window.stepskit || {};
    stepskit._q = stepskit._q || [];

    // Queue API calls made before the SDK finishes loading.
    var methods = ['identify', 'setUserAttributes', 'track', 'refresh', 'playTour', 'stopTour', 'dismissAnnouncement', 'on', 'off', 'destroy', 'validateEnvironment'];
    methods.forEach(function (method) {
      stepskit[method] = stepskit[method] || function () {
        stepskit._q.push([method].concat([].slice.call(arguments)));
      };
    });

    // Load the StepsKit SDK asynchronously.
    var script = document.createElement('script');
    script.async = true;
    script.src = 'https://cdn.stepskit.com/stepskit.latest.js';
    script.setAttribute('data-api-key', 'YOUR_API_KEY');

    var first = document.getElementsByTagName('script')[0];
    first.parentNode.insertBefore(script, first);
  })();
</script>

Your src/app.html should end up looking like this:

<body data-sveltekit-preload-data="hover">
  <div style="display: contents">%sveltekit.body%</div>

  <!-- StepsKit loader goes here -->
</body>

2. Identify the user

This is half the install, not a nice-to-have. Without an id, show-once capping degrades to a per-tab check and any tour with a "filtered" audience is hidden outright.

Resolve the user in src/routes/+layout.server.ts and identify from +layout.svelte:

// src/routes/+layout.server.ts
export const load = async ({ locals }) => {
  return { user: locals.user };
};
<!-- src/routes/+layout.svelte -->
<script>
  import stepskit from "stepskit";

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

  stepskit.init("YOUR_API_KEY");

  $effect(() => {
    if (!data.user) return;
    stepskit.identify({
      id: data.user.id,
      email: data.user.email,
      plan: data.user.plan,
    });
  });
</script>

{@render children()}

No browser guard needed: +layout.svelte runs on the server too, but every method on the package is a no-op there rather than a crash. (If you installed without the package, you do need one — window doesn't exist during SSR.)

On Svelte 4, use onMount with $props replaced by export let data — the StepsKit call is identical.

Pass whatever your user object already carries; every key becomes targetable and there's no fixed schema to conform to. Re-call it when the user switches workspace or changes plan, so what you target on doesn't go stale.

3. Verify it worked

Load any page of your app, then:

  1. Dashboard — the project's Integrations page flips to Connected.
  2. Console, on a signed-in page — run window.stepskit.validateEnvironment(). You want a non-null visitorId and a populated userAttributes. A null visitorId means step 2 never ran.
  3. Networkcdn.stepskit.com/stepskit.latest.js returns 200.

validateEnvironment() also returns a toursFiltered table that says exactly why any given tour isn't showing.

SvelteKit notes

Route changes need no extra wiring. StepsKit patches history.pushState and replaceState and listens for popstate, so it sees SvelteKit navigation on its own. You don't need afterNavigate from $app/navigation.

src/app.html is not reactive. It renders once per full page load. That's exactly the lifetime you want for a global script — don't try to move it into a component.

Content Security Policy

SvelteKit is unusual in shipping first-class CSP generation in svelte.config.js. If you use it, this is the part to get right.

StepsKit needs:

  • script-src: https://cdn.stepskit.com, plus 'unsafe-inline' for the loader block above
  • connect-src: https://stepskit.com
  • style-src: 'unsafe-inline'required. The embed injects its entire stylesheet as a <style> element when the script evaluates. Without this the tour engine renders completely unstyled rather than merely off-brand.
  • img-src: https://*.supabase.co — only if your steps use images.

The conflict: browsers ignore 'unsafe-inline' in any directive that also carries a hash or a nonce. SvelteKit "will augment the specified directives with nonces or hashes (depending on mode) for any inline styles and scripts it generates" — so once it touches style-src, the 'unsafe-inline' StepsKit needs is silently disabled and the tour engine renders unstyled.

Three ways out:

  1. Nonce the loader instead of allowing inline scripts. src/app.html supports a %sveltekit.nonce% placeholder, so you can hand SvelteKit's nonce to the StepsKit block directly:

    <script nonce="%sveltekit.nonce%">
      // …the StepsKit loader…
    </script>

    This solves script-src cleanly. It does not solve style-src — the embed injects its stylesheet at runtime, and you can't nonce that.

  2. Keep style-src out of SvelteKit's CSP management. Set StepsKit's style-src 'unsafe-inline' in a header you control, and let SvelteKit manage only script-src. This is the part you actually have to solve.

  3. Load the SDK without an inline block. Use a plain external tag in src/app.html<script async src="…" data-api-key="…"> — so script-src needs no 'unsafe-inline' at all. You lose the queue stub, so pass user data as data-user-* attributes rather than calling identify().

Note that auto mode uses nonces for dynamically rendered pages and hashes for prerendered ones, so a site with both will hit this on the prerendered half even if the dynamic half looks fine.

Troubleshooting

Dashboard still says "Not connected". Check the snippet is in src/app.html and not in a +layout.svelte — a layout re-renders and would load the SDK repeatedly.

window is not defined during build or SSR. A StepsKit call is running server-side. Guard it with browser from $app/environment, or move it into onMount / $effect.

Tour renders as unstyled text. Your CSP is stripping style-src 'unsafe-inline' — almost always SvelteKit's csp config in hash or nonce mode. See above.

CSP console errors about the inline script. Same cause, script-src side. Use option 2 above.

Connected, but no tour plays. Usually identity. Run validateEnvironment(): if visitorId is null, your identify isn't running — check the browser guard isn't short-circuiting it permanently.

Next steps