Next Starter Logo

Onboarding

How the post-signup onboarding wizard works in Next Starter: capture a profile and plan, then gate the dashboard behind the User.onboardingComplete flag.

How it works

When a user first signs up, a two-step wizard runs before they can use the app. Step one collects a name and avatar. Step two asks them to pick a plan (or stay on free). When they finish, the dashboard unlocks.

One boolean on the User model decides everything: onboardingComplete. While it is false, the user is sent to the wizard. Once it flips to true, they reach the dashboard.

The UI lives under app/onboarding/. The database write lives in app/actions/onboarding.ts.

The redirect gate

Two layouts watch the flag and push the user to the right place. Each one reads the flag off the session with getSession() (lib/server/auth-helpers.ts).

LayoutRule
app/dashboard/layout.tsxflag is false → redirect to /onboarding
app/onboarding/layout.tsxflag is true → redirect to /dashboard

The two rules point in opposite directions. Together they trap the user in the wizard until they finish, and keep finished users out of the wizard. The dashboard check runs on every request under /dashboard, so no dashboard page is reachable until the flag flips. Both layouts also redirect to /auth/sign-in when there is no session.

The flag reaches the session because it is declared under user.additionalFields in lib/auth.ts (type: "boolean", input: false). input: false means clients cannot set it through the normal updateUser call. Only the server action below writes it. The matching column is onboardingComplete Boolean @default(false) in prisma/schema.prisma.

The session uses a 5-minute cookie cache (session.cookieCache in lib/auth.ts). After the flag flips, that cache still holds the old false value. You must refresh the session or the gate keeps bouncing the user back to /onboarding. See Completion.

Entry points

New users land on /onboarding from two places. Returning users go straight to /dashboard, and the gate bounces them back only if their flag is still false.

SourceSends the user to
app/auth/verify-email/ (after email OTP)/onboarding
GoogleSignInButton on the register page/onboarding (via its callbackUrl prop)
app/auth/sign-in/ (returning users)/dashboard

The wizard

app/onboarding/page.tsx is a server component. It loads the session and renders OnboardingWizard (onboarding-wizard.tsx), a client component that tracks the active step with a useState counter.

StepComponentPurpose
0ProfileStep (profile-step.tsx)Avatar upload and name
1PlanStep (plan-step.tsx)Pricing table, or a summary of a pending plan

The progress dots come from the STEP_IDS tuple (["profile", "plan"]) in onboarding-wizard.tsx. A fixed header holds an X button that lets the user skip from either step: it calls completeOnboarding, refreshes the session, then sends them to /dashboard. The only required field anywhere is name.

Profile step. Uses React Hook Form with zodResolver(updateProfileSchema) from lib/validations/user.ts. On submit it, in order: removes the avatar via removeUserAvatar if the user cleared it, or uploads a new one (resized by resizeForAvatar) through getAvatarUploadUrl + processAvatarAfterUpload; updates the name via Better Auth updateUser only if it changed; then advances to the plan step.

Plan step. Reads a pendingSubscription entry from localStorage. The register form sets it when the user arrives from a pricing link such as /auth/register?plan=pro&annual=true:

interface PendingSubscription { plan: string; annual: boolean; timestamp: number }

The entry expires after 24 hours, and plan: "free" is treated as no selection. A valid entry shows a confirmation card; otherwise the full PricingTable renders. Picking a paid plan calls client.subscription.upgrade with successUrl: "/onboarding/complete" and cancelUrl: "/onboarding". Picking free routes straight to /onboarding/complete. See Billing for the upgrade flow.

Completion

app/onboarding/complete/page.tsx is where every path converges. The Stripe success redirect and the free-plan route both land here. It fires confetti, then runs this in a useEffect:

  1. Call completeOnboarding(). On failure it shows a toast and sends the user back to /onboarding.
  2. Wait about 2 seconds, then call refreshOnboardingSession().
  3. Set window.location.href = "/dashboard".

The server action (app/actions/onboarding.ts) has two functions:

FunctionWhat it does
completeOnboardingSets onboardingComplete: true on the user via Prisma. Returns { success, error? }.
refreshOnboardingSessionCalls auth.api.getSession with query: { disableCookieCache: true } to bust the 5-minute cookie cache.

The refresh step matters. Without it the cached cookie still reports onboardingComplete: false, and the dashboard gate bounces the user back to /onboarding. Both the skip button and the complete page refresh the session before redirecting.

What you can change

Add or remove a step. Build a client component under app/onboarding/ (copy profile-step.tsx as a template). Add its id to STEP_IDS in onboarding-wizard.tsx so a progress dot appears. Then add a currentStep === N branch that renders it and calls advance() when done. To remove a step, delete its branch and its STEP_IDS entry.

Persist new profile fields. Add the column in prisma/schema.prisma and run prisma migrate. If you want the value on the session, declare it under user.additionalFields in lib/auth.ts. Write to it from a server action. Today only name flows through updateProfileSchema.

Change the redirect target. The post-onboarding destination is set in two places: the window.location.href in complete/page.tsx, and the skip handler in onboarding-wizard.tsx. The gate targets (/onboarding, /dashboard) live in the two layouts.

Make it non-skippable. Remove the header X button from onboarding-wizard.tsx. The dashboard gate then bounces users until they reach /onboarding/complete.

Remove onboarding entirely. Default onboardingComplete to true in prisma/schema.prisma (and set defaultValue: true in lib/auth.ts). Drop the guard in app/dashboard/layout.tsx. Point the verify-email redirect and the register GoogleSignInButton callbackUrl at /dashboard.

On this page