# Install StepsKit in a Rails app

> Add StepsKit to a Rails app from application.html.erb with javascript_include_tag. Covers Turbo Drive navigation, CSP nonces, why importmap is the wrong place, and identifying current_user.

*Source: https://stepskit.com/docs/install/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](/app).
- 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](/docs/install.md).
- **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

```bash
npm install stepskit
```

Then call `init()` at the top of `app/javascript/application.js`:

```js
import stepskit from "stepskit";

stepskit.init("YOUR_API_KEY");
```

Skip to [step 2](#2-identify-the-user) — 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>`:

```erb
<%= 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:

```erb
<%= 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:

```erb
<%= 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:

```erb
<% 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. **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.

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

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

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