Astro

Install StepsKit (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.
  • Astro 4 or 5.

1. Install the package

npm install stepskit

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

---
// 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 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:

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

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

// 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():

---
// 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. Networkcdn.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:

<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