# Install StepsKit in a SvelteKit app

> Add StepsKit to SvelteKit with the stepskit npm package in your root layout, identify users, and understand why SvelteKit's built-in CSP config conflicts with the embed's required style-src.

*Source: https://stepskit.com/docs/install/sveltekit*

For SvelteKit 2. If you're on plain Svelte + Vite with no SvelteKit, skip to
[that section](#plain-svelte-vite) — the entrypoint is different.

## Prerequisites

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

## 1. Install the package

```bash
npm install stepskit
```

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

```html
<!-- 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](/docs/install/react.md#without-the-component) for the same pattern.

### Without the package

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

```html
<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:

```html
<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`:

```ts
// src/routes/+layout.server.ts
export const load = async ({ locals }) => {
  return { user: locals.user };
};
```

```html
<!-- 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. **Network** — `cdn.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:

   ```html
   <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

- [JavaScript API reference](/docs/api.md) — every method on `window.stepskit`.
- [User attributes](/docs/concepts/user-attributes.md) — what you can target on.
- [Frequency capping](/docs/concepts/frequency-capping.md) — how "show once" works.
