Open the console

React onboarding checklist: one component, mounted once

A React onboarding checklist is a custom element, not a component tree. Mount it once and report step completion from the code that knows it happened.

React onboarding checklist: one component, mounted once

React gives you one obvious way to do almost everything, which is why the first instinct with an embedded onboarding checklist is to rebuild it as a component: a step array, a state machine, a context provider, a few hooks. You do not have to. The checklist ships as a custom element with its own shadow root, and the React package is a thin wrapper over it. You mount it once, pass the user id when you have one, and report step completion from the code that already knows the step happened.

This is the integration as it is meant to be written, and the four places where a React app usually fights the widget instead.

The widget is an element, not a component tree

The SDK is a Web Component. It registers a <getting-started> tag, renders inside it, and owns its own styles. The npm package @gs/react maps props to attributes (projectKey becomes project-key), turns the widget's events into callback props, and exposes the imperative API through a ref. React is a peer dependency and is never bundled, so the wrapper does not add a second copy of React to your app.

Two consequences follow. Your global stylesheet cannot reach inside the widget, and you style it through the --gs-* custom properties instead — that is deliberate, since a checklist has to survive being embedded in a product nobody on your team wrote. And the element is not reconciled like a normal child: React sets its attributes, the widget manages everything below them. What you control from React is attributes in, callbacks out.

Install it, then mount it in the app shell

Add the package and render the wrapper once, above your routes:

npm install @gs/react

// App.tsx
import { GettingStarted } from '@gs/react'

export function App({ me }) {
  return (
    <>
      <GettingStarted
        endpoint="https://gettingstarted.marthaia.com"
        projectKey="pk_your_publishable_key"
        userId={me?.id}
        locale="en"
        onStepCompleted={(key, state) => track('gs_step', { key, percent: state.progress.percent })}
        onCompletedAll={() => track('gs_all_steps_done')}
        onError={(error) => console.warn('gs', error.code, error.message)}
      />
      <Router />
    </>
  )
}

endpoint is your instance's base URL and projectKey is the publishable pk_… key from the console. The publishable key belongs in the bundle: it is the key the browser uses to read the project's config and state, and it reaches nothing else. The sk_… secret key must never end up in anything a browser downloads; it exists for the server-to-server calls only.

Mount it in the shell rather than inside a page component. A checklist mounted in a page that unmounts on navigation boots again on every route change. Nothing is lost — progress is stored server-side against the visitor or the user — but the panel closes and the widget re-reads its config each time. One mount in the layout removes that whole class of problem, including the version where two routes render two checklists for the same person.

Pass the user id after your session resolves

Leave userId empty until you have one, exactly as me?.id does above. The widget generates a visitor id, keeps it in localStorage, and writes progress against it. The moment user-id goes from empty to a value, it calls /identify once and merges that progress into the user. That is what makes a checklist usable before signup: a visitor completes two steps on your marketing site, signs up, and keeps them.

What breaks it is remounting the element per user — putting userId in the key prop, or rendering the wrapper in two places. The state is server-side, so the progress survives; what you pay is a redundant config fetch and a widget that visibly restarts on login.

Load one bundle, not two

The SDK ships as an IIFE (/sdk/v1/gs.min.js, which registers the tag and exposes window.GS) and as ESM (/sdk/v1/gs.esm.js). Each carries its own copy of the element class, and the first registration wins. In a React app you have a bundler, so import @gs/react and let the bundler handle it; the <script> tag is for hosts with no build step.

Report completion from the code that knows

Clicking a step does not complete it. The widget dispatches a gs:step-clicked event and lets your link navigate, because it cannot know whether the user actually did the thing the step describes. Completion comes from you, in one of two shapes:

  • From the client, when the action happened in the same session: gs.complete('first_project'), through the ref or the useGettingStarted() hook.
  • From your backend, when the step is a fact only the server sees — an import finished, a webhook delivered, an invite accepted: POST /api/gs/v1/server/complete with { "userId": "u_1829", "stepKey": "first_project" } and the secret key.

Either way, complete a step from a real event your product emitted, not from the widget's own UI. A checklist that ticks steps because a checkbox was clicked measures clicks, and that number will disagree with reality the first time someone opens the panel twice.

SSR, styling, and the kill switch

Importing the SDK on the server touches no window and no document, and the wrapper renders only the element tag with its attributes, then hydrates on the client. In a framework with server components, put the import in a client component and let the boundary do the work. For the theme, set --gs-* properties on the element rather than reaching into the shadow root; the palette comes from the project config, and a host value set before the first render wins.

Two attributes are worth knowing before launch. disabled makes the element render nothing and never boot — a kill switch you can ship in advance behind a flag, so an onboarding fire can be put out without a deploy. And meta takes facts about the person (plan, admin or not, signup cohort) and stores them with the subject, visible in the console; the server never interprets them.

See it before you ship it, then measure it

Draft a list in the console and open the preview — it drives the real widget from a config override, with no network calls, which is the fastest way to check labels and step order in the layout they will actually appear in. The React props and callbacks are listed in the docs, next to the plain-HTML version of the same embed.

Then decide what the checklist is for before you look at it again. The number that matters is not how many people opened the panel; it is the share of new accounts that reach your activation event in the first session — choose that event first and map the checklist's last step onto it, so the funnel in the console and the number in your dashboard are the same fact. If you have not picked a list to build yet, the design rules are the other half of this.

Try it on your app