GenerateSaaS

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

ConditionRedirect target
config.waitlist === trueconfig.routes.home (gate never runs)
onboardingCompleted !== trueconfig.routes.onboarding
Multi-tenant + onboardingCompleted !== true + zero organizationssame onboarding route
Onboarding complete (+ has 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.
  • When config.tenancy.multiTenant is true (the default), the form doubles as first-org creation so no one lands org-less.
  • The /onboarding layout (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 same config.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.

FieldRequiredNotes
nameyesMin 2 chars. Pre-filled from the session, auto-focused.
countryyesPre-selected from useGeo() when detectable.
phonenoOptional.
orgNamemulti-tenant + first orgOnly rendered when multiTenant && needsOrg.
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 }) - this flips the redirect gate off.
Create the first organization (multi-tenant + needsOrg only) via authClient.organization.create({ name, slug }) with an auto-generated slug, then setActive.
Call api.onboarding.complete.$post() (best-effort, wrapped in try/catch) to schedule the welcome and follow-up sequence.
Hard-navigate to the validated ?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 });
});
ResponseMeaning
{ 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.

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 marketing send, the job re-checks marketingOptIn; if a user opted out it pauses up to 3 days waiting for user/marketing.enabled, then ends (success: false, reason: "marketing_disabled_timeout") if not re-enabled.
  • Delayed marketing emails carry an unsubscribeUrl from generateUnsubscribeUrl(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.

On this page