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).
| Layout | Rule |
|---|---|
app/dashboard/layout.tsx | flag is false → redirect to /onboarding |
app/onboarding/layout.tsx | flag 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.
| Source | Sends 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.
| Step | Component | Purpose |
|---|---|---|
| 0 | ProfileStep (profile-step.tsx) | Avatar upload and name |
| 1 | PlanStep (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:
- Call
completeOnboarding(). On failure it shows a toast and sends the user back to/onboarding. - Wait about 2 seconds, then call
refreshOnboardingSession(). - Set
window.location.href = "/dashboard".
The server action (app/actions/onboarding.ts) has two functions:
| Function | What it does |
|---|---|
completeOnboarding | Sets onboardingComplete: true on the user via Prisma. Returns { success, error? }. |
refreshOnboardingSession | Calls 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.
Authentication
How Next Starter handles authentication with Better Auth — email/password and Google sign-in, email OTP verification, password resets, roles, and sessions.
Billing & Subscriptions
How Next Starter runs Stripe billing and subscriptions via the Better Auth Stripe plugin: plans, checkout, the customer portal, webhooks, and lifecycle emails.