Launch NowDocs

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

FileRole
app/auth/onboarding/page.tsxThe /auth/onboarding page. Renders OnboardingForm.
features/onboarding/onboarding-form.tsxThe four-step form (client component).
features/onboarding/building-options.tsAnswers of the "What are you building?" step.
lib/onboarding-actions.tscompleteOnboarding(): marks onboarding as done.
lib/onboarding.tsgetSetupStatus(): reads checklist progress from the database.
features/onboarding/use-setup-progress.tsuseSetupProgress(): the checklist items (titles, links, icons).
features/onboarding/setup-progress-card.tsxChecklist card on the dashboard.
features/onboarding/onboarding-complete-banner.tsxFloating "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:

StepQuestionRequired
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

features/onboarding/onboarding-form.tsx
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)
  }
}
  1. The name is saved if it changed.
  2. The organization is created with the Better Auth organization plugin and set as the active organization.
  3. completeOnboarding() sets user.onboardingCompleted to true and revalidates the whole app.
  4. The user lands on /dashboard with 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

lib/onboarding-actions.ts
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:

KeyDone when
nameThe user has a non-empty name.
teamThe user sent an invitation, or another user is a member of one of the user's organizations.
apiKeyThe user has at least one API key.
planThere 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.tsx calls getSetupStatus(session.user) and renders OnboardingCompleteBanner: a floating Complete onboarding button in the bottom right corner that expands into the checklist.
  • app/(app)/(dash)/dashboard/page.tsx passes the same status to DashboardContent, which renders SetupProgressCard at 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():

features/onboarding/use-setup-progress.ts
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 Step type (1 | 2 | 3 | 4), the segments rendered by SegmentedProgress ([1, 2, 3, 4]), the "Step N of 4" label in StepLabel, and the setStep() calls of the Back and Continue buttons.
  • Change the answers of step 3 in seatOptions, and those of step 4 in features/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).

drizzle/auth-schema.ts
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:

lib/onboarding-actions.ts
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

features/onboarding/onboarding-form.tsx
await completeOnboarding({ teamSize: seats, building: building || undefined })

Add a checklist item

Add the key to SetupStatus

lib/onboarding.ts
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.