GenerateSaaS

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=….

ConditionRedirect target
config.waitlist === trueconfig.routes.home - the gate never runs
onboardingCompleted !== trueconfig.routes.onboarding
Multi-tenant, onboarding incomplete, zero organizationsSame onboarding route
Onboarding complete (with an org when multi-tenant)Passes through to the dashboard
  • onboardingCompleted is a Better Auth user field added via additionalFields, not a column you write by hand.
  • With config.tenancy.multiTenant on (the default), the form silently creates the first organization so nobody lands org-less - it never asks for a name.
  • The /onboarding layout 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.

FieldRequiredNotes
nameyesMin 2 chars, pre-filled from the session, auto-focused
countryyesPre-selected from useGeo() when detectable
phonenoOptional
marketingOptIn-Checkbox renders only in GDPR countries, defaulting to false there; non-GDPR users default to true with no checkbox
Persist the profile with authClient.updateUser({ name, country, phone, marketingOptIn, onboardingCompleted: true }), which flips the redirect gate off.
Create the first organization (multi-tenant plus needsOrg only) via authClient.organization.create({ name, slug }) - the name is config.tenancy.defaultOrganizationName (default "Personal"), the slug is auto-generated - then setActive.
Call api.onboarding.complete.$post() (best-effort, wrapped in try/catch) to schedule the welcome sequence.
Hard-navigate to the validated ?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
ResponseMeaning
{ 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.

StepWaitEmail template
Welcomeimmediatewelcome (founder-style)
Getting-started check-inday 3getting-started-check-in
Feedback requestday 14feedback-request
  • The welcome email is part of the signup flow: it sends regardless of marketingOptIn and carries no unsubscribe link.
  • Before each delayed send the job re-checks marketingOptIn. If the user opted out it waits up to 3 days for user/marketing.enabled, then ends with reason: "marketing_disabled_timeout".
  • Delayed emails carry an unsubscribeUrl from generateUnsubscribeUrl(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.

On this page