# Install StepsKit in an Astro site

> Add StepsKit to Astro with the stepskit npm package in a bundled layout script. Covers Astro script directives, view transitions with ClientRouter, and identifying users from middleware.

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

For Astro sites, static or server-rendered. Astro's script directives are the
one thing that decides whether this works at all, so read step 1 rather than
skimming it.

## Prerequisites

- Your project API key, from the **Integrations** page in the
  [dashboard](/app).
- Astro 4 or 5.

## 1. Install the package

```bash
npm install stepskit
```

Put this in whichever layout wraps every page — typically
`src/layouts/Layout.astro`:

```html
---
// src/layouts/Layout.astro
---
<html lang="en">
  <body>
    <slot />

    <script>
      import stepskit from "stepskit";

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

### Do NOT add `is:inline` here

This is the Astro rule that trips people up, and with the package it runs the
opposite way to the snippet install.

By default Astro **processes** a bare `<script>`: it bundles the contents,
resolves imports, and — in Astro's words — processed scripts "become
`type="module"` automatically". That bundling is exactly what you want here,
because the script has an `import` in it. Adding `is:inline` would tell Astro
to emit the tag "exactly as written", leaving a bare `import` the browser
can't resolve.

The reverse holds for the [snippet install](#without-the-package) below: that
one is inline code with no imports, so it *does* need `is:inline` or Astro
mangles it.

### Without the package

If you can't add a dependency, paste the snippet into the same layout, just
before `</body>` — and this time `is:inline` **is** mandatory:

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

```html
<script is:inline>
  // …the StepsKit loader from above…
</script>
```

Useful related rule: Astro won't process a `<script>` that carries any
attribute other than `src`. So a plain external tag is left alone too:

```html
<script
  is:inline
  async
  src="https://cdn.stepskit.com/stepskit.latest.js"
  data-api-key="YOUR_API_KEY"
></script>
```

You lose the queue stub this way. That matters here: an identify call written
inline runs immediately, long before an async engine finishes downloading — so
with this form `window.stepskit` is still undefined and the call is silently
dropped. If you use the external tag, pass user data as `data-user-*`
attributes on it instead of calling `identify()`.

## 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.

Astro renders on the server, so the natural approach is to resolve the user in
middleware and render the values straight into the page — no client-side auth
round-trip needed.

```ts
// src/middleware.ts
import { defineMiddleware } from "astro:middleware";

export const onRequest = defineMiddleware(async (context, next) => {
  context.locals.user = await getUserFromSession(context.cookies);
  return next();
});
```

Render the user as JSON, then read it from the same bundled script that calls
`init()`:

```html
---
// src/layouts/Layout.astro
const { user } = Astro.locals;
---
{user && (
  <script
    type="application/json"
    id="stepskit-user"
    set:html={JSON.stringify({ id: user.id, email: user.email, plan: user.plan })}
  />
)}

<script>
  import stepskit from "stepskit";

  stepskit.init("YOUR_API_KEY");

  const el = document.getElementById("stepskit-user");
  if (el?.textContent) stepskit.identify(JSON.parse(el.textContent));
</script>
```

**Don't reach for `define:vars` here.** It forces `is:inline`, and an inline
script runs at parse time while the bundled one is a deferred module — so the
identify call would fire before `init()` had run. Reading the JSON from inside
the bundled script keeps both in the same execution, in the right order.

For a fully static site with no server render, call `identify` from your own
client-side auth code instead — anywhere you already know who the visitor is.

Pass whatever your user object already carries; every key becomes targetable and
there's no fixed schema to conform to.

## 3. Verify it worked

Load any page of your site, 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.

## Astro notes

### View transitions

If you use Astro's client-side router (`<ClientRouter />`, called
`<ViewTransitions />` before Astro 5), navigation no longer reloads the page —
and **scripts do not re-run after a swap**.

For `init()` that's fine and even desirable: it should run once per full page
load, which is exactly what happens. But your `identify` call won't re-run
either, so if the signed-in user can change without a full reload, hook the
lifecycle event:

```html
<script>
  import stepskit from "stepskit";

  document.addEventListener("astro:page-load", () => {
    const el = document.getElementById("stepskit-user");
    if (el?.textContent) stepskit.identify(JSON.parse(el.textContent));
  });
</script>
```

`astro:page-load` fires on the initial load **and** after every swap, which is
why it's the right event rather than `astro:after-swap`.

StepsKit itself needs no help here — it patches `history.pushState` and
`replaceState` and listens for `popstate`, so it sees view-transition navigation
and re-evaluates which tours apply on its own.

### Content Security Policy

Only relevant if your site sets one. Astro's own CSP support is still
experimental, so most projects set these as response headers from their host or
an adapter:

- `script-src`: `https://cdn.stepskit.com`. The package's bundled script needs
  no `'unsafe-inline'`; only the `is:inline` snippet form does.
- `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.

## Troubleshooting

**Dashboard still says "Not connected", but the script is in the built HTML.**
If you used the snippet form, you're missing `is:inline` — Astro bundled and
hoisted the loader. This is by
far the most common cause.

**Works on the first page, then stops after navigating.** You're on
`<ClientRouter />` and something that needed to re-run didn't. The loader is
supposed to run once; if it's your `identify` call, move it to
`astro:page-load`.

**Tour renders as unstyled text.** Your CSP is missing
`style-src 'unsafe-inline'`.

**Connected, but no tour plays.** Usually identity. Run
`validateEnvironment()`: if `visitorId` is null, the `identify` block isn't
running — check `Astro.locals.user` is actually populated on that route.

## 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.
