Onboarding
What is this
Onboarding is the post-signup setup that takes a brand-new owner from “I just made a Clerk account” to “my organization exists, has at least one program, legal consent is recorded, and a welcome email landed”. It runs at /{lang}/(protected)/onboarding.
The whole setup is one sheet on one page (SetupSheet), not a sequence of screens. Three sections hang from a rail of markers: org (business name + the owner’s first and last name), train (“How do you train people?”, required, no default, each ticked answer names a starter program) and legal (the owner documents). A sticky footer names what is still missing and carries the single submit button. There is no progress bar, no back/continue navigation and no summary screen.
OnboardJS (@onboardjs/react) is still underneath, reduced to a single engine step. It is kept for resumable local persistence and the flow-level analytics hooks, with a PostHog plugin for analytics.
After the sheet, setup continues inside the dashboard as the setup guide (SetupGuide): a card that promotes exactly one next step beside a rail of all steps. It renders on Overview and, for owners and admins, on /dashboard/home.
Optional follow-up data beyond the sheet (class type name/duration/capacity, invitations) can still be sent to POST /organizations/:id/onboarding-complete; the sheet itself never populates it.
Who uses it
| Persona | Why |
|---|---|
| New owner | Required gate before reaching the dashboard. No memberships → routed here. |
| Existing owner with another org | Skipped. The server-side guard in onboarding/page.tsx redirects if memberships exist. |
| Staff of a new org | Overview renders the setup guide for whoever can open Overview (no role gate of its own); /dashboard/home renders it for owners and admins only. It stays until every actionable step is done or the guide is dismissed. |
The sheet is never seen by coaches or members. They arrive via invitation, which carries them straight to an active membership. Onboarding is owner-creation-only.
Persona impact
- Server-side guard in
apps/web/src/app/[lang]/(protected)/onboarding/page.tsxcallsgetCurrentUserServer. Ifmemberships.length > 0, redirects to either/{lang}/dashboard/overview(staff role) or/{lang}(member, or no active membership). Only zero-membership users see the sheet. - LocalStorage persistence: the OnboardingProvider uses
localStoragePersistence: { key:'taikan:setup-sheet-v5', ttl: 7 days }. The key is versioned per step shape, so a half-finished multi-step flow never resumes into an engine that only knows the single sheet step. A successful submit removes the key. - Analytics:
OnboardingProviderruns withflowId='setup-sheet',flowVersion='5.0.0'.createPostHogPluginis configured witheventPrefix='onboarding_'and the global propertyonboarding_variant='v5-sheet'. The engine only sees the onesheetstep, so section-level progress is reported by hand throughcaptureOnboardingEvent(providers/onboarding-provider.tsx): a section settling emitsonboarding_step_completedwithstep_idorg,trainorlegal, and a successful submit emitsonboarding_flow_completed.
High-level capabilities
- Three sections, one page:
org(business name + owner’s name),train(how you train people),legal(owner documents). Each is a React component underapps/web/src/components/onboarding/steps/, rendered together bysetup-sheet.tsx. - Rail markers: every section hangs from a
StepMarkerthat iscurrent(the first unanswered section),upcomingorsettled. The same marker is reused by the dashboard setup guide so the guide reads as the sheet continued. - Headline and button carry the gym name: the headline shows the typed name (a fallback phrase until one is typed) and the submit button is labelled with it.
- Footer status: a sticky footer lists what is still missing (“a name”, “how you train”, “a name for your first program”, “the terms”). Each item is a button that scrolls to and focuses its field. When nothing is missing the footer says so and the submit button enables.
trainsection: two multi-select tiles, no default: “Classes on a schedule” (boxes, studios, group training) and “Coaching programs” (online coaches, 1:1, small cohorts). No Courses tile. Each ticked tile grows a required inline “Name your first one” input. Tile + name-input UI is the sharedDeliveryModePickercomponent (apps/web/src/components/onboarding/delivery-mode-picker.tsx), reused by Train’s “add a way to train” sheet (see dashboard-shell) so the interaction matches in both places.- Validity in one place:
getMissingSetupItemsinapps/web/src/lib/onboarding/setup-sheet-state.tsdecides what is missing; the footer, the markers, the submit button and the Enter-key routing all read from it. - State container: OnboardJS
flowDataholdsorgName,deliveryModes(Array<'schedule'|'coaching'>),programNames(Partial<Record<'schedule'|'coaching', string>>),legalConsentsAccepted(typed viaSetupWizardContext). - handleSubmit (
onboarding-content.tsx) runs in this order:POST /organizationswith{ name, type, locale, currency, timezone }.- If
legalConsentsAccepted=true:POST /legal/consentswith{ types: OWNER_LEGAL_DOC_TYPES, context:'owner_onboarding', organizationId }. POST /organizations/:id/onboarding-completewith{ deliveryModes, programNames }. This endpoint owns the idempotent “seed a starter program per mode” logic (plus the class-type/location/welcome-email side effects that ride along with it), so the sheet calls it rather than the programs API.OrganizationsService.completeOnboardingcreates oneprogramsrow per entry indeliveryModes, named fromprogramNames[mode]when present and falling back to a per-locale default (ONBOARDING_DEFAULTS) otherwise.- Emit
onboarding_flow_completed, remove the persistence key, invalidate the/users/meReact Query. router.push('/{lang}/dashboard/overview'). A retry after a partial failure resumes where it stopped: the created org id and the recorded consents are remembered in refs, so a second submit never creates a duplicate organization.
- Inline errors: a failed submit renders in the footer (
role="alert") in place of the status line. No toast. - Setup guide:
apps/web/src/components/overview/setup-guide.tsx, driven byhooks/use-getting-started.ts. See behavior. - Other components in the folder:
AddressAutocomplete,DaySelector,InviteMembersFormandCreateOrgFormlive inapps/web/src/components/onboarding/but are not rendered by the sheet.
Relationship to other features
- users-auth: sign-up immediately precedes onboarding.
getCurrentUserServerpowers the guard. - organizations: the sheet’s payoff is
POST /organizations, thenPOST /:id/onboarding-complete. - memberships:
POST /organizationsauto-inserts the owner membership;InviteMembersForm(if used) callsPOST /:orgId/invitations. - dashboard-shell: Train’s sidebar derives its items from the programs the
trainsection created; owners/admins can add another way to train later from Train’s ”+ Add a way to train” row, which reuses the same tile UI and posts straight to the programs API. The optional/dashboard/homelanding renders the setup guide too. - dashboard: Overview renders the setup guide under its page guide.
- Legal / consents:
LegalAcceptanceFormruns embedded (standalone={false}) and reports its all-boxes-ticked state throughonAcceptedChange; the consent rows are written byPOST /legal/consentswithcontext='owner_onboarding'at submit. - Platform billing / tiers:
platformTierdefaults toliteon create. Setup-guide steps that needautomated_billingrender as locked Pro upsells on tiers without it and link to/dashboard/settings/billing.
Current status
Shipped as the single setup sheet plus the dashboard setup guide. The legacy plan in docs/_archive/plans-legacy/FIT-93-guided-onboarding.md describes a fuller multi-step wizard (plan picker, location, invite sub-steps) and a modal guided tour; neither exists in the code.
There is no modal guided tour. users.guided_tour_completed_at and the guidedTourCompletedAt field on PATCH /users/me still exist in the API, but no web code reads or writes them. lib/session-reset.ts still clears the old taikan:guided-tour-completed localStorage key.
Known gaps
- The sheet has no UI to populate the remaining optional fields of
onboarding-complete(classTypeName, defaultDurationMin, defaultCapacity, inviteEmails). The richer post-onboarding setup happens through other surfaces (the setup guide, settings, members invite dialog). - The setup guide marks
setupSchedulecomplete from the presence of class types, not from scheduled sessions, so it settles together withcreateClassType. Sinceonboarding-completeseeds a default class type for a schedule program, both are settled from the first dashboard view for an org created through the sheet. - The setup guide’s dismissed flag is a single localStorage key (
taikan:getting-started-dismissed), per browser rather than per organization. - The retry-resume memory (created org id, recorded consents) lives in component refs, so it survives a retry but not a page reload.
- No “skip onboarding” escape hatch for testing. The server-side guard requires zero memberships. E2E suites bypass via the standard
usePersona/storageStateplumbing. - No API-level unit test covers
OrganizationsService.completeOnboarding.