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

A new signup hits a two-step wizard before the app opens up: profile, then plan. One boolean on the User model, onboardingComplete, decides when it ends.

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

The redirect gate

Two layouts read the flag off the session with getSession() (lib/server/auth-helpers.ts) and push in opposite directions. app/dashboard/layout.tsx sends you to /onboarding while the flag is false. app/onboarding/layout.tsx sends you to /dashboard once it's true. Every page under /dashboard sits inside that layout, so the redirect covers every dashboard route. Both layouts also redirect to /auth/sign-in when there's no session.

The flag reaches the session because it's declared under user.additionalFields in lib/auth.ts with input: false, which stops clients from setting 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

Two routes send a new user to /onboarding: app/auth/verify-email/ after the email OTP, and the register page's GoogleSignInButton, which passes callbackUrl="/onboarding". Sign-in points everyone at /dashboard instead, and the gate bounces them back only if their flag is still false.

The wizard

app/onboarding/page.tsx loads the session and renders OnboardingWizard (onboarding-wizard.tsx), which tracks the active step with a useState counter. Step 0 is ProfileStep (profile-step.tsx), the avatar and name. Step 1 is PlanStep (plan-step.tsx), which shows either the pricing table or a summary of a plan the user already picked.

The progress dots come from the STEP_IDS tuple (["profile", "plan"]) in onboarding-wizard.tsx. The dot for the step immediately behind the current one is a button, so someone on the plan step can click it to go back to their profile. 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. Submitting does the avatar first: removeUserAvatar if the user cleared it, otherwise an upload through getAvatarUploadUrl and processAvatarAfterUpload, resized in the browser by resizeForAvatar first when canResizeAvatar allows it. The name goes through Better Auth updateUser, and only when it actually changed. Then the wizard advances.

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 the step treats plan: "free" as no selection. A stored entry shows a confirmation card naming the tier, with the price beside it when the plan id matches one in lib/pricing.ts. A "Choose a different plan" button swaps in the full table. Without an entry, PricingTable renders straight away. Picking a paid plan calls authClient.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 the plan step's paths converge. The Stripe success redirect and the free-plan route both land here. The skip button goes straight to /dashboard instead. The page sets metadata and renders the client component CompleteContent (complete/complete-content.tsx), which fires confetti and runs this in a useEffect:

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

app/actions/onboarding.ts holds both halves of that. completeOnboarding sets onboardingComplete: true on the user via Prisma. refreshOnboardingSession calls auth.api.getSession with query: { disableCookieCache: true } to bust the cookie cache described above. Anything you add that finishes onboarding needs to call both, in that order, the way the skip button and the complete page do.

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. PlanStep is last and never calls advance(). It routes to checkout or /onboarding/complete, so a step after it means changing those routes too. To remove a step, delete its branch and its STEP_IDS entry, then renumber the branches that follow.

Persist new profile fields. Add the column in prisma/schema.prisma and run pnpm prisma migrate dev. 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 lives in two places: the window.location.href in complete/complete-content.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. That page is still open to anyone signed in, because completeOnboarding only checks for a session, so put any hard requirement inside that action.

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