Onboarding
The four-step onboarding flow after sign-up, the setup checklist on the dashboard, and how to change steps and checklist items.
New users go through a short onboarding flow right after sign-up: they set their name, create their first organization and answer two questions. Once in the app, a setup checklist guides them through the next actions (invite the team, create an API key, pick a plan) until everything is done.
Where the code lives
| File | Role |
|---|---|
app/auth/onboarding/page.tsx | The /auth/onboarding page. Renders OnboardingForm. |
features/onboarding/onboarding-form.tsx | The four-step form (client component). |
features/onboarding/building-options.ts | Answers of the "What are you building?" step. |
lib/onboarding-actions.ts | completeOnboarding(): marks onboarding as done. |
lib/onboarding.ts | getSetupStatus(): reads checklist progress from the database. |
features/onboarding/use-setup-progress.ts | useSetupProgress(): the checklist items (titles, links, icons). |
features/onboarding/setup-progress-card.tsx | Checklist card on the dashboard. |
features/onboarding/onboarding-complete-banner.tsx | Floating "Complete onboarding" button on every app page. |
The onboarding flow
After a successful sign-up, the sign-up form sends the user to /auth/onboarding: with an email code it
calls router.push(callbackURL ?? "/auth/onboarding"), and the GitHub and Google buttons pass
/auth/onboarding as newUserCallbackURL. Returning users who sign in go to /dashboard and skip it.
OnboardingForm keeps every answer in React state and shows a four-segment progress bar:
| Step | Question | Required |
|---|---|---|
| 1 | "How should we call you": the display name, prefilled from the session. | No, the step has a Skip for now link. |
| 2 | "Name your organization": the organization name. The slug is derived from it and shown read-only. | Yes, Continue is disabled until the slug is not empty. |
| 3 | "How big is your team?": one of 1, 2-5, 6-10, 11-25, 25+. | Defaults to 1. |
| 4 | "What are you shipping?": one of the buildingOptions. | No. |
The slug comes from slugify() in lib/utils.ts, which lowercases the name, strips accents and removes
every character that isn't a letter or a digit: Acme Corp becomes acmecorp.
What happens on Finish
async function handleFinish() {
setIsSubmitting(true)
try {
const trimmed = name.trim()
if (trimmed && trimmed !== session?.user?.name) {
await authClient.updateUser({ name: trimmed })
}
const { data } = await authClient.organization.create({
name: orgName,
slug: orgSlug,
})
if (data) {
await authClient.organization.setActive({ organizationId: data.id })
}
await completeOnboarding()
toast.add({ title: `Welcome to ${siteConfig.name}!`, type: "success" })
router.push("/dashboard")
} catch {
toast.add({
title: "Something went wrong. Please try again.",
type: "error",
})
} finally {
setIsSubmitting(false)
}
}- The name is saved if it changed.
- The organization is created with the Better Auth organization plugin and set as the active organization.
completeOnboarding()setsuser.onboardingCompletedtotrueand revalidates the whole app.- The user lands on
/dashboardwith a welcome toast.
Good to know: The team size (step 3) and the "What are you building?" answer (step 4) are collected but not saved anywhere. Wire them to the database if you need them; see Save more answers.
Good to know: The Better Auth client returns errors instead of throwing. If the organization can't be
created (for example because the slug is already taken), onboarding still completes and the user reaches
the dashboard without an organization. They can create one from /orgs.
completeOnboarding
const completeOnboardingAction = authAction
.inputSchema(
z
.object({
name: z.string().trim().min(1).max(32).optional(),
fileBase64: z.string().max(MAX_BASE64_LENGTH).optional(),
fileName: z.string().optional(),
fileType: z.string().optional(),
})
.optional()
)
.action(async ({ ctx, parsedInput }) => {
// Optionally uploads the avatar and updates name/image, then:
await db
.update(user)
.set({ onboardingCompleted: true })
.where(eq(user.id, ctx.user.id))
revalidatePath("/", "layout")
return { success: true }
})
/** Input is optional so the form can call `completeOnboarding()` directly. */
export async function completeOnboarding(
input?: Parameters<typeof completeOnboardingAction>[0]
) {
return completeOnboardingAction(input)
}The action can also set the display name and upload an avatar (sent as base64, validated as a PNG, JPEG or WebP image of 2 MB at most; see File storage). The current form only calls it without arguments.
The onboarding_completed column is not used to guard any route: a user who leaves the flow can still use
the app. If you want to force onboarding, read the column in app/(app)/layout.tsx and redirect to
/auth/onboarding when it is false. The column is not declared in Better Auth's additionalFields, so it
isn't on session.user: query the user table with db, or declare it under user.additionalFields in
lib/auth.ts.
The setup checklist
Once in the app, users see what's left to set up. Progress is computed from real data, not from flags, so an item is checked as soon as the user does the thing from anywhere in the app.
Progress data
getSetupStatus(user) in lib/onboarding.ts runs four queries in parallel and returns booleans:
| Key | Done when |
|---|---|
name | The user has a non-empty name. |
team | The user sent an invitation, or another user is a member of one of the user's organizations. |
apiKey | The user has at least one API key. |
plan | There is an active or trialing subscription for the user, or for an organization where the user is owner or admin. |
It is wrapped in React cache(), so the app layout and the dashboard share one set of queries per request.
Where it is shown
app/(app)/layout.tsxcallsgetSetupStatus(session.user)and rendersOnboardingCompleteBanner: a floating Complete onboarding button in the bottom right corner that expands into the checklist.app/(app)/(dash)/dashboard/page.tsxpasses the same status toDashboardContent, which rendersSetupProgressCardat the top of the dashboard: a progress bar, and one row per item with a link.
Both components return null when every item is done.
Checklist items
The titles, descriptions and links live in useSetupProgress():
const items: SetupItem[] = [
{
key: "name",
title: "Add your name",
description: "Set your display name so your team knows who you are.",
actionLabel: "Edit profile",
href: "/account",
icon: UserRound,
done: status.name,
},
{
key: "team",
title: "Invite your team",
// ...
href: "/orgs",
icon: Users,
done: status.team,
},
{
key: "api-key",
title: "Create an API key",
// ...
href: "/account/keys",
icon: KeyRound,
done: status.apiKey,
},
{
key: "plan",
title: "Choose a plan",
// ...
href: "/account/billing",
icon: CreditCard,
done: status.plan,
},
]The hook returns items, remaining, doneCount and percent.
Because the status is read on the server, it refreshes when the page re-renders. Server actions that call
revalidatePath refresh it automatically; client-only mutations should call router.refresh(), as the API
key hooks in features/account/api-keys/use-api-keys.ts do.
Customize
Change the steps
Everything lives in features/onboarding/onboarding-form.tsx:
- Edit the text and inputs of a step inside its
{step === N && (...)}block. - To add or remove a step, update the
Steptype (1 | 2 | 3 | 4), the segments rendered bySegmentedProgress([1, 2, 3, 4]), the "Step N of 4" label inStepLabel, and thesetStep()calls of the Back and Continue buttons. - Change the answers of step 3 in
seatOptions, and those of step 4 infeatures/onboarding/building-options.ts. - Change where users land after onboarding in the
router.push("/dashboard")call.
Save more answers
To keep the team size and the project type:
Add columns
Add the columns to the user table in drizzle/auth-schema.ts, then generate and apply a migration (see
Database).
export const user = pgTable("user", {
// ...
onboardingCompleted: boolean("onboarding_completed").default(false),
teamSize: text("team_size"),
building: text("building"),
})Accept them in the action
Extend the input schema of completeOnboardingAction and include the values in the update:
z.object({
// ...
teamSize: z.string().max(10).optional(),
building: z.string().max(50).optional(),
})
// ...
await db
.update(user)
.set({
onboardingCompleted: true,
teamSize: parsedInput?.teamSize,
building: parsedInput?.building,
})
.where(eq(user.id, ctx.user.id))Send them from the form
await completeOnboarding({ teamSize: seats, building: building || undefined })Add a checklist item
Add the key to SetupStatus
export type SetupStatus = {
name: boolean
team: boolean
apiKey: boolean
plan: boolean
project: boolean
}Compute it in getSetupStatus
Add a query to the Promise.all and return the boolean. Keep each query cheap: it runs on every page of the
app while the checklist is incomplete.
Describe it in useSetupProgress
Add an item with a key, title, description, actionLabel, href, a lucide-react icon and
done: status.project. The card, the banner and the percentage pick it up automatically.
To remove an item, delete it from useSetupProgress() and drop the matching query and key.