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.json and run yarn 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 stepskit

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

  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.

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]
end

style_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