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 stepskitPut 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:
- Dashboard — the project's Integrations page flips to Connected.
- Console, on a signed-in page — run
window.stepskit.validateEnvironment(). You want a non-nullvisitorIdand a populateduserAttributes. A nullvisitorIdmeans step 2 never ran. - Network —
cdn.stepskit.com/stepskit.latest.jsreturns200.
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 theis:inlinesnippet form does.connect-src:https://stepskit.comstyle-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 — every method on
window.stepskit. - User attributes — what you can target on.
- Frequency capping — how "show once" works.