Remix / React Router 7

Install StepsKit (Remix / React Router 7)

For Remix v2 and React Router 7 in framework mode. Remix was merged into React Router 7, so if your imports come from react-router rather than @remix-run/react you're on the newer one — the install below is the same either way, and only the import paths differ.

Prerequisites

  • Your project API key, from the Integrations page in the dashboard.
  • Remix v2 or React Router 7 in framework mode. If you're using React Router as a plain client-side library inside a Vite app, follow the React guide instead.

1. Install the package

npm install stepskit

Render <StepsKit /> in the Layout export in app/root.tsx, after <Scripts />:

// app/root.tsx
import { Links, Meta, Outlet, Scripts, ScrollRestoration } from "react-router";
import { StepsKit } from "stepskit/react";

export function Layout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <head>
        <Meta />
        <Links />
      </head>
      <body>
        {children}
        <ScrollRestoration />
        <Scripts />
        <StepsKit apiKey="YOUR_API_KEY" />
      </body>
    </html>
  );
}

export default function App() {
  return <Outlet />;
}

On Remix v2, import from @remix-run/react instead of react-router. Nothing else changes.

Layout wraps every route and mounts once per full page load, so StepsKit loads once and stays put across client-side navigation.

About ordering

The package installs its call queue synchronously, before the engine finishes downloading — so any identify() or playTour() call you make is buffered and replayed in order rather than dropped. There's no window where the API is missing, and nothing to guard 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.

Return the user from the root loader, then hand it to the same component — there is no separate identify call to write:

// app/root.tsx
export async function loader({ request }: Route.LoaderArgs) {
  const user = await getUserFromSession(request);
  return { user };
}
// app/root.tsx — inside Layout
import { useRouteLoaderData } from "react-router";

const data = useRouteLoaderData<{ user?: User }>("root");
const user = data?.user;

<StepsKit
  apiKey="YOUR_API_KEY"
  user={user ? { id: user.id, email: user.email, plan: user.plan } : undefined}
/>;

The component identifies the visitor when user appears and re-identifies when its values change — passing a new object with the same values does nothing, so a re-rendering loader won't cause repeat calls. It's safe on the server too: everything it does is client-only.

Pass whatever your user object already carries; every key becomes targetable and there's no fixed schema to conform to. When the user switches workspace or changes plan, the loader re-runs and the new values are picked up.

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.

Remix notes

Route changes need no extra wiring. StepsKit patches history.pushState and replaceState and listens for popstate, so it sees client-side navigation on its own. You don't need useLocation plumbing or a refresh() call.

root.tsx runs on the server. <StepsKit /> is safe there — it renders nothing and does all its work on the client. Your own window access isn't; keep that in effects or event handlers.

Content Security Policy. Remix and React Router thread a nonce through their script components, and StepsKit takes one the same way:

<ScrollRestoration nonce={nonce} />
<Scripts nonce={nonce} />
<StepsKit apiKey="YOUR_API_KEY" nonce={nonce} />

StepsKit needs:

  • script-src: https://cdn.stepskit.com
  • 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.

Because StepsKit loads via an external <script src> and not an inline block, you do not need script-src 'unsafe-inline'. The nonce prop is only needed if your policy requires a nonce on every script.

Troubleshooting

Dashboard still says "Not connected". Check <StepsKit /> is in Layout and not in the default App export — only Layout renders the document shell.

window is not defined during SSR. A StepsKit call escaped an effect. Move it inside useEffect or a handler.

Connected, but no tour plays. Usually identity. Run validateEnvironment(): if visitorId is null, useRouteLoaderData("root") probably isn't returning a user — confirm the root loader actually runs on that route.

Tour renders as unstyled text. Your CSP is missing style-src 'unsafe-inline'.

Property 'stepskit' does not exist on type 'Window'. Add the ambient declaration from the API reference to app/.

Next steps