Onboarding
Gate every new user through a post-signup profile step and kick 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 (apps/web-next/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 (gate never runs) |
onboardingCompleted !== true | config.routes.onboarding |
Multi-tenant + onboardingCompleted !== true + zero organizations | same onboarding route |
| Onboarding complete (+ has org when multi-tenant) | passes through to the dashboard |
onboardingCompletedis a Better Auth user field (added viaadditionalFields), not a column you write by hand.- When
config.tenancy.multiTenantistrue(the default), the form doubles as first-org creation so no one lands org-less. - The
/onboardinglayout (app/[locale]/onboarding/layout.tsx) runs an auth guard only - never the onboarding redirect - so users can't loop on the page itself. It also honors the sameconfig.waitlist→ home redirect. See Marketing & SEO.
The form
The client form lives at components/onboarding/onboarding-form.tsx. It 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. |
orgName | multi-tenant + first org | Only rendered when multiTenant && needsOrg. |
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 }) - this flips the redirect gate off.needsOrg only) via authClient.organization.create({ name, slug }) with an auto-generated slug, then setActive.api.onboarding.complete.$post() (best-effort, wrapped in try/catch) to schedule the welcome and follow-up sequence.?redirect=… param or config.routes.loginRedirect, so the next request reads the post-onboarding session.POST /onboarding/complete
The route is packages/api/src/routes/internal/onboarding.ts - POST /complete, mounted at /onboarding, so the typed client path is api.onboarding.complete.$post(). It runs behind authGuard and 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
new Hono().post("/complete", authGuard, async (c) => {
const session = c.get("session");
const user = await db.query.users.findFirst(/* id, email, name */);
if (!user) return c.json({ error: "User not found" }, 404);
const claimed = await db.insert(processedWebhookEvents)
.values({ id: `user_onboarding_completed_${user.id}`, provider: "lifecycle", eventType: "user_onboarding_completed" })
.onConflictDoNothing()
.returning({ id: processedWebhookEvents.id });
if (claimed.length === 0) return c.json({ success: true, emailsScheduled: false });
await inngest.send({
name: "user/onboarding.completed",
data: { userId, email, name },
});
return c.json({ success: true, emailsScheduled: true });
});| Response | Meaning |
|---|---|
{ success: true, emailsScheduled: true } | Durable lifecycle claim created; user/onboarding.completed event sent. |
{ success: true, emailsScheduled: false } | Onboarding event was already claimed - nothing new scheduled. |
{ error: "User not found" } (404) | Session user row missing. |
The user-onboarding job
The 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 marketing send, the job re-checks
marketingOptIn; if a user opted out it pauses up to 3 days waiting foruser/marketing.enabled, then ends (success: false,reason: "marketing_disabled_timeout") if not re-enabled. - Delayed marketing emails carry an
unsubscribeUrlfromgenerateUnsubscribeUrl(userId, config.baseUrl)(@repo/notifications). - See Background jobs for the Inngest setup, and Email for the templates and provider.
Frequently asked questions
Can I turn onboarding off?
There is no flag - it always runs once per user. To make it a pass-through, replace the form's fields and submit logic with a single authClient.updateUser({ onboardingCompleted: true }) call on mount, which flips the redirect gate.
What if a user closes the tab mid-form?
onboardingCompleted stays false, so the dashboard redirect sends them back to /onboarding on their next visit. Nothing is half-committed.
Why didn't a user get the welcome email?
Check whether api.onboarding.complete.$post() ran and whether the Inngest worker processed user/onboarding.completed. marketingOptIn only affects the delayed check-in and feedback emails.
Does completing onboarding twice send duplicate emails?
No. The route stores a durable lifecycle claim before dispatching the onboarding event, so repeat calls return emailsScheduled: false.
Authentication
Signup, sessions, and the user fields onboarding writes (onboardingCompleted, marketingOptIn).
Background jobs
The Inngest runtime behind the user-onboarding drip sequence.
Organizations
The first-org creation onboarding triggers in multi-tenant mode.
The welcome, check-in, and feedback templates the sequence sends.