Onboarding
Build a Nuxt onboarding flow that gates new users after signup, collects profile and organization details, and triggers a welcome email sequence.
Onboarding collects first-run profile details and (in multi-tenant mode) the user's first organization. It is always on (not flag-gated), served at config.routes.onboarding (default /onboarding), with the email follow-up scheduled by @repo/api.
State
Onboarding reads two Better Auth user fields (defined as additionalFields in packages/auth/src/config.ts):
| Field | Type | Default | Role |
|---|---|---|---|
onboardingCompleted | boolean | false | The gate - !== true triggers the redirect |
marketingOptIn | boolean | true | Whether delayed marketing follow-up emails run |
The redirect has a second trigger: when config.tenancy.multiTenant is true, a user who completed onboarding but has no organization is also sent back to /onboarding to create one.
How it works
Redirect: apps/web-nuxt/app/middleware/auth.ts sends any user with onboardingCompleted !== true (or, in multi-tenant mode, no organization) to /onboarding, preserving the intended path as ?redirect=. Pages bypass it with skipOnboarding: true in definePageMeta.
Form: apps/web-nuxt/app/pages/onboarding.vue collects name, country (pre-filled from the geo-detected country), phone, and a marketing opt-in. When config.tenancy.multiTenant is true and the user has no org, it also asks for an organization name.
Submit: on submit it persists the profile via authClient.updateUser (setting onboardingCompleted: true), optionally creates the org via authClient.organization.create, then POSTs to api.onboarding.complete.$post().
Redirect out: a hard navigation goes to the validated ?redirect= target, falling back to config.routes.loginRedirect.
The marketing opt-in checkbox only renders in GDPR countries (detected via useGeo()), where it defaults to off. useGeo() treats an unknown country as GDPR (safer default), so the checkbox shows whenever geo is unresolved. In confirmed non-GDPR countries the checkbox is hidden and marketingOptIn stays true.
The complete endpoint
POST /complete (packages/api/src/routes/internal/onboarding.ts, behind authGuard) schedules the welcome and follow-up emails. It stores a durable lifecycle claim before dispatching the Inngest event, so repeated client calls do not trigger duplicate function runs.
// packages/api/src/routes/internal/onboarding.ts
// - loads id, email, and name for the authenticated user
// - claims `user_onboarding_completed_{userId}` in processedWebhookEvents
// - sends the "user/onboarding.completed" Inngest event when the claim is newThat event triggers the user-onboarding Inngest function (packages/api/src/functions/lifecycle/user-onboarding.ts) - a 3-step sequence (3 retries, cancelOn user/deleted):
| Step | Timing | |
|---|---|---|
send-welcome-email | immediate | welcome (founder-style) |
send-getting-started-check-in | after 3 days | getting-started-check-in |
send-feedback-request | 11 days later (day 14) | feedback-request |
The welcome email is part of the signup flow: it sends regardless of marketingOptIn and carries no unsubscribe link. Before the day-3 and day-14 marketing emails, each step re-checks marketingOptIn; if the user opted out mid-sequence it waits up to 3 days for a user/marketing.enabled event and ends the sequence if not re-enabled in time. See Background jobs for how Inngest functions run.
Frequently asked questions
Can I turn onboarding off?
There is no feature flag. To skip it, remove the onboardingCompleted check in apps/web-nuxt/app/middleware/auth.ts.
Where is the first organization created?
In the form's submit handler via authClient.organization.create, only when config.tenancy.multiTenant is true and the user has no org. See Organizations.
What if the user never finishes?
onboardingCompleted stays false, so the middleware redirects them back to /onboarding on the next authenticated navigation.
How do I add custom fields?
Extend onboarding.vue and pass the new values to authClient.updateUser, which persists them on the user record.