Next Starter Logo

Onboarding

A two-step post-signup wizard that captures a profile, prompts for a plan, and gates the dashboard behind a User.onboardingComplete flag.

Overview

Next Starter ships with a two-step onboarding wizard that runs once after a user first signs up. It collects a name and avatar, asks the user to pick a plan (or continue on free), and then unlocks the dashboard. The flow is enforced server-side via a single boolean column on the User model.

All onboarding code lives under app/onboarding/, with the database mutation in app/actions/onboarding.ts.

When It Triggers

The dashboard layout calls getSession() and redirects to /onboarding whenever session.user.onboardingComplete is falsy:

// app/dashboard/layout.tsx
const session = await getSession();
if (!session) redirect("/auth/sign-in");
if (!session.user.onboardingComplete) redirect("/onboarding");

The check runs on every dashboard request, so a user cannot reach /dashboard (or any nested route) until the flag is true. The onboarding layout enforces the opposite: a user whose onboarding is already complete is sent straight back to the dashboard.

// app/onboarding/layout.tsx
const session = await getSession();
if (!session) redirect("/auth/sign-in");
if (session.user.onboardingComplete) redirect("/dashboard");

The verify-email page also redirects to /onboarding once the OTP is accepted, and the Google sign-in button on the register page is configured with callbackUrl="/onboarding". The sign-in page sends returning users directly to /dashboard — if their onboarding is incomplete, the dashboard layout bounces them back to /onboarding.

The Schema Field

onboardingComplete is a non-nullable boolean on the User model with a default of false:

// prisma/schema.prisma
model User {
  // ...
  onboardingComplete Boolean @default(false)
  // ...
}

It is also declared as an additionalFields entry on Better Auth so the value appears on the session object and stays in sync with the cookie cache:

// lib/auth.ts
user: {
  additionalFields: {
    onboardingComplete: {
      type: "boolean",
      required: false,
      defaultValue: false,
      input: false,
    },
  },
  // ...
}

input: false means clients cannot set the flag through the standard updateUser call — only the server-side action below writes to it.

The Wizard

app/onboarding/page.tsx is a server component that loads the session and renders OnboardingWizard. The wizard is a client component with two steps, tracked by a useState counter:

StepComponentPurpose
0ProfileStepAvatar upload and name
1PlanStepPricing table or pending-subscription summary

The header includes an X button that skips the wizard at any point by calling the same completeOnboarding action used at the end of the flow. There is no required field beyond name, and the entire wizard can be dismissed.

Profile Step

profile-step.tsx uses React Hook Form with zodResolver(updateProfileSchema) from lib/validations/user.ts:

// lib/validations/user.ts
export const updateProfileSchema = z.object({
  name: nameSchema, // trimmed, 1–32 characters
});

On submit, the step optionally uploads an avatar (resizing via resizeForAvatar when possible) through getAvatarUploadUrl and processAvatarAfterUpload, optionally removes an existing avatar via removeUserAvatar, and updates the name via Better Auth's updateUser if it changed. Once those calls succeed, it advances to the plan step.

Plan Step

plan-step.tsx reads pendingSubscription from localStorage. This is set by the register form when the user arrives from a pricing link (/auth/register?plan=pro&annual=true) and is stored as:

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

The entry expires after 24 hours, and a plan: "free" value is treated as no pending subscription. If a valid entry exists, the step shows a confirmation card with the selected plan and a "Continue to checkout" button. Otherwise it renders the full PricingTable.

Picking a paid plan calls client.subscription.upgrade with successUrl: "/onboarding/complete" and cancelUrl: "/onboarding". Picking the free plan navigates directly to /onboarding/complete.

Complete Page

app/onboarding/complete/page.tsx is the convergence point. It fires confetti, calls completeOnboarding, waits two seconds, refreshes the session, and then sends the browser to /dashboard:

// app/onboarding/complete/page.tsx
useEffect(() => {
  completeOnboarding().then(async (result) => {
    if (!result.success) {
      toast.error(result.error || "Something went wrong");
      window.location.href = "/onboarding";
      return;
    }
    await new Promise((r) => setTimeout(r, 2000));
    await refreshOnboardingSession();
    window.location.href = "/dashboard";
  });
}, []);

Both the Stripe success redirect and the free-plan path land here, so the same code completes onboarding regardless of which option the user picked.

The Server Action

app/actions/onboarding.ts exports two functions. completeOnboarding flips the flag on the user record:

// app/actions/onboarding.ts
export async function completeOnboarding(): Promise<ApiResponse> {
  const session = await getSession();
  if (!session) return { success: false, error: "Unauthorized" };

  try {
    await prisma.user.update({
      where: { id: session.user.id },
      data: { onboardingComplete: true },
    });
    return { success: true };
  } catch {
    return { success: false, error: "Failed to complete onboarding" };
  }
}

refreshOnboardingSession invalidates the 5-minute Better Auth cookie cache so the updated onboardingComplete: true value is visible on the next request without waiting for the cache to expire:

export async function refreshOnboardingSession(): Promise<void> {
  await auth.api.getSession({
    headers: await headers(),
    query: { disableCookieCache: true },
  });
}

Without this call, a user could land back on /onboarding because the stale cookie still reports onboardingComplete: false. The wizard always calls refreshOnboardingSession before redirecting to /dashboard.

Validation

The only field validated during onboarding is the user's name, via the shared updateProfileSchema:

  • Trimmed
  • Minimum 1 character
  • Maximum 32 characters

The plan step does not run client-side validation — Stripe Checkout handles billing details, and the free-plan path has nothing to validate.

Extending the Flow

To add a new step:

  1. Build a new client component under app/onboarding/ (mirror profile-step.tsx).
  2. Add its id to the STEP_IDS tuple in onboarding-wizard.tsx so the progress dots render correctly.
  3. Add a currentStep === N branch to the wizard that renders your component and calls advance() when finished.

If your step needs to persist new fields on the user, add them to the Prisma schema, run prisma migrate, and declare them under user.additionalFields in lib/auth.ts if you want them surfaced on the session.

Skipping the Flow

The wizard already ships with a skip button in the header that completes onboarding without collecting any input. To remove onboarding entirely:

  1. Default onboardingComplete to true in prisma/schema.prisma (and update the defaultValue in lib/auth.ts).
  2. Remove the redirect guard from app/dashboard/layout.tsx.
  3. Point the register form, verify-email page, and GoogleSignInButton callback URL to /dashboard instead of /onboarding.

To make onboarding non-skippable, remove the header X button from onboarding-wizard.tsx. The dashboard layout will continue to bounce users back until they reach /onboarding/complete.

On this page