Onboarding
Gate every new user through a post-signup profile step that also creates their first organization and kicks off the welcome email sequence.
Onboarding is always on (no flag) and lives at config.routes.onboarding (default /onboarding). Every authenticated user is forced through it once, after which it records onboardingCompleted on the user and fires the user-onboarding Inngest sequence in @repo/api.
The redirect gate
The dashboard layout (app/[locale]/(dashboard)/layout.tsx) redirects any authenticated user who has not finished onboarding. buildAuthRedirect(...) returns a locale-prefixed URL with the current path encoded as ?redirect=….
| Condition | Redirect target |
|---|---|
config.waitlist === true | config.routes.home - the gate never runs |
onboardingCompleted !== true | config.routes.onboarding |
| Multi-tenant, onboarding incomplete, zero organizations | Same onboarding route |
| Onboarding complete (with an org when multi-tenant) | Passes through to the dashboard |
onboardingCompletedis a Better Auth user field added viaadditionalFields, not a column you write by hand.- With
config.tenancy.multiTenanton (the default), the form silently creates the first organization so nobody lands org-less - it never asks for a name. - The
/onboardinglayout runs an auth guard only, never the onboarding redirect, so users cannot loop on the page itself.
The form
components/onboarding/onboarding-form.tsx (desktop: apps/desktop/src/renderer/screens/onboarding.tsx) collects these fields, then runs the submit flow below.
| Field | Required | Notes |
|---|---|---|
name | yes | Min 2 chars, pre-filled from the session, auto-focused |
country | yes | Pre-selected from useGeo() when detectable |
phone | no | Optional |
marketingOptIn | - | Checkbox renders only in GDPR countries, defaulting to false there; non-GDPR users default to true with no checkbox |
authClient.updateUser({ name, country, phone, marketingOptIn, onboardingCompleted: true }), which flips the redirect gate off.needsOrg only) via authClient.organization.create({ name, slug }) - the name is config.tenancy.defaultOrganizationName (default "Personal"), the slug is auto-generated - then setActive.api.onboarding.complete.$post() (best-effort, wrapped in try/catch) to schedule the welcome sequence.?redirect=… param or config.routes.loginRedirect, so the next request reads the post-onboarding session.To add a step or a field, edit that component: put the field in onboarding-schema.ts, render it in the form, and read it in onSubmit. There is no step framework or plugin API to register with.
POST /onboarding/complete
The route lives at packages/api/src/routes/internal/onboarding.ts, mounted at /onboarding behind authGuard, so the typed client path is api.onboarding.complete.$post(). It stores a durable lifecycle claim before dispatching the Inngest event, so repeat calls never schedule duplicate emails.
// packages/api/src/routes/internal/onboarding.ts
// - loads id, email, and name for the authenticated user (404 if the row is gone)
// - claims `user_onboarding_completed_{userId}` in processedWebhookEvents
// - sends the "user/onboarding.completed" Inngest event only when the claim is new| Response | Meaning |
|---|---|
{ success: true, emailsScheduled: true } | Claim created, user/onboarding.completed sent |
{ success: true, emailsScheduled: false } | Already claimed - nothing new scheduled |
{ error: "User not found" } (404) | Session user row missing |
The user-onboarding sequence
That event drives the user-onboarding Inngest function (packages/api/src/functions/lifecycle/user-onboarding.ts) - a multi-day sequence with retries: 3, cancelled on user/deleted.
| Step | Wait | Email template |
|---|---|---|
| Welcome | immediate | welcome (founder-style) |
| Getting-started check-in | day 3 | getting-started-check-in |
| Feedback request | day 14 | feedback-request |
- The welcome email is part of the signup flow: it sends regardless of
marketingOptInand carries no unsubscribe link. - Before each delayed send the job re-checks
marketingOptIn. If the user opted out it waits up to 3 days foruser/marketing.enabled, then ends withreason: "marketing_disabled_timeout". - Delayed emails carry an
unsubscribeUrlfromgenerateUnsubscribeUrl(userId, config.baseUrl).
To make onboarding a pass-through instead of a form, replace the form's fields and submit logic with a single authClient.updateUser({ onboardingCompleted: true }) call on mount.
Waitlist Mode
Flip one config flag to turn the app into a pre-launch capture page - signups create accounts and collect emails but cannot sign in until you launch.
Deployment
Pick the deploy target and architecture at generatesaas init, avoid the blocked combinations, and ship to a host that supports them.