Ruby on Rails
Install StepsKit (Ruby on Rails)
For Rails 7 and 8. Rails is a multi-page app, which changes how you identify users — see step 2 — and it ships Turbo, which changes how navigation works.
Prerequisites
- Your project API key, from the Integrations page in the dashboard.
- Rails 7 or newer. Older versions work; only the Turbo notes won't apply.
Which install you need
Rails ships two different JavaScript setups, and they take different installs:
- jsbundling-rails (esbuild, rollup, or webpack — you have a
package.jsonand runyarn build): install the npm package. - importmap-rails (the Rails 7+ default, no bundler): use the layout tag below.
Not config/importmap.rb
On importmap-rails the instinct is to pin StepsKit there. Don't: importmaps map
ES module specifiers to browser-loadable URLs, and the CDN loader is a
classic script, not a module. Pinning it does nothing useful. There's also no
node_modules for an importmap to resolve stepskit out of, so the package
isn't an option on this path either — it belongs in the layout.
1a. With jsbundling — install the package
npm install stepskitThen call init() at the top of app/javascript/application.js:
import stepskit from "stepskit";
stepskit.init("YOUR_API_KEY");Skip to step 2 — but read the Turbo notes below, they apply either way.
1b. With importmap — add the loader to your layout
Add this to app/views/layouts/application.html.erb, inside <head>:
<%= javascript_include_tag "https://cdn.stepskit.com/stepskit.latest.js",
async: true,
data: { api_key: "YOUR_API_KEY" } %>Rails converts the data: hash into dashed attributes, so this renders
data-api-key="YOUR_API_KEY" — no string-building needed.
<head> rather than before </body>, and this matters on Rails. Turbo Drive
replaces <body> on every visit and re-executes the scripts it finds there, but
it merges <head> and leaves tags that are already present alone. In <head>
the loader runs once per hard page load; in <body> it would re-run on every
Turbo navigation and start a fresh SDK instance each time. async means it
still doesn't block rendering.
Put the API key in credentials rather than the template once you're past trying it out:
<%= javascript_include_tag "https://cdn.stepskit.com/stepskit.latest.js",
async: true,
data: { api_key: Rails.application.credentials.stepskit_api_key } %>Because this form has no inline queue stub, guard any JavaScript calls you write
later with window.stepskit?..
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.
In a multi-page app, user attributes do not survive a page load. Identifying on sign-in alone does nothing for the pages that follow. The simplest correct answer is to render the values as data attributes on the same tag, so they're present on every request:
<%= javascript_include_tag "https://cdn.stepskit.com/stepskit.latest.js",
async: true,
data: {
api_key: Rails.application.credentials.stepskit_api_key,
**(current_user ? {
user_id: current_user.id,
user_email: current_user.email,
user_plan: current_user.plan
} : {})
} %>That renders data-user-id, data-user-email and data-user-plan, which
StepsKit reads on load. ERB escapes attribute values, so this is safe with
user-supplied data.
Prefer the data-attribute form above. StepsKit reads those attributes off the tag during its own startup, so there is no ordering to get wrong.
If you'd rather call the API explicitly, tag.script with nonce: true keeps
it CSP-clean — but put it at the end of <body>, and note that
window.stepskit doesn't exist until the async SDK has downloaded, so an early
call is silently dropped:
<% if current_user %>
<%= tag.script nonce: true do %>
window.stepskit?.identify(<%= {
id: current_user.id.to_s,
email: current_user.email,
plan: current_user.plan
}.to_json.html_safe %>);
<% end %>
<% end %>to_json on a Hash is the safe way to get a value into a script block — don't
interpolate fields individually.
Pass whatever your user model already carries; every key becomes targetable and there's no fixed schema to conform to.
3. Verify it worked
Load any page of your app, 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.
Rails notes
Turbo Drive
Rails 7+ ships Turbo Drive, which intercepts link clicks and replaces <body>
rather than doing a full page load. Two consequences:
Navigation is client-side. StepsKit handles this already — it patches
history.pushState and replaceState and listens for popstate, so it sees
Turbo navigation and re-evaluates which tours apply. You don't need to hook
turbo:load for StepsKit's sake.
Your <body> gets replaced. An element a tour step is anchored to can be
swapped out from under an open tour. Anchor steps to elements that persist
across the pages a tour spans, or keep each step's target on the page that step
belongs to.
Don't move the loader into <body>. Turbo re-executes body scripts on every
visit, and each evaluation constructs a fresh SDK instance that replaces the
previous one — duplicated listeners and a redundant fetch per navigation. Keep
the loader in <head>, where Turbo's head merge leaves it alone.
Because the SDK survives Turbo navigation, user attributes set once do persist across Turbo visits. They still don't survive a hard reload, which is why step 2 renders them on every request.
Content Security Policy
Rails has the cleanest nonce story of any stack here. In
config/initializers/content_security_policy.rb:
Rails.application.configure do
config.content_security_policy do |policy|
policy.script_src :self, "https://cdn.stepskit.com"
policy.connect_src :self, "https://stepskit.com"
policy.style_src :self, :unsafe_inline
policy.img_src :self, :data, "https://*.supabase.co"
end
config.content_security_policy_nonce_generator =
->(request) { request.session.id.to_s }
config.content_security_policy_nonce_directives = %w[script-src]
endstyle_src :unsafe_inline is required, not cosmetic: the embed injects its
entire stylesheet as a <style> element when the script evaluates. Without it
the tour engine renders completely unstyled.
Note that nonce_directives lists only script-src. If you add style-src
there, the browser will ignore unsafe_inline on that directive — nonces and
'unsafe-inline' are mutually exclusive — and StepsKit will render unstyled.
javascript_include_tag picks up the nonce automatically when script-src is
in nonce_directives.
Troubleshooting
Dashboard still says "Not connected". The layout you edited isn't the one
that controller renders. Check for other layouts in app/views/layouts/.
Show-once tours reappear on every visit. Identity isn't being set on each page load. In an MPA the data attributes (or the identify block) must be in the global layout.
Tour renders as unstyled text. Your CSP is missing style_src :unsafe_inline, or style-src is listed in content_security_policy_nonce_directives.
Tour loses its highlight after clicking a link. Turbo replaced the anchored element. See above.
Connected, but no tour plays. Run validateEnvironment(): if visitorId is
null, current_user is nil in the layout on that page.
Next steps
- JavaScript API reference — every method on
window.stepskit. - User attributes — what you can target on.
- Frequency capping — how "show once" works.