For the complete documentation index, see llms.txt. Prefer markdown by appending .md to documentation URLs or sending Accept: text/markdown.

Onboarding flow

Build a TurboStarter web onboarding wizard after signup, persist completion on the server, and optionally require a paid plan before the dashboard.

After purchase, most SaaS products need a short post-signup path: collect profile or workspace data, optionally take payment, then land in the dashboard. TurboStarter reserves empty slots for this (apps/web/src/modules/onboarding/ and apps/web/src/app/[locale]/dashboard/(user)/onboarding/) and already redirects authenticated users into the dashboard layout.

This recipe fills those slots with a production pattern: server-backed completion, a multi-step UI, and an optional hard paywall before the product.

TL;DR

  1. Persist onboardingCompleted (Better Auth additional field or a small table) - not only localStorage.
  2. Guard the dashboard layout: incomplete users → /dashboard/onboarding.
  3. Build steps under modules/onboarding + the reserved route folder.
  4. Soft monetization: show upgrade CTAs; hard paywall: incomplete billing → /dashboard/choose-plan until getActivePlan() is paid.
  5. Point post-auth callbackURL / redirectTo at onboarding for new accounts.

Soft vs hard paywall

TurboStarter defaults to a Free plan via getActivePlan() when there is no subscription. That is intentional freemium.

ModeWhat users can do after signupImplementation
SoftFinish onboarding → use Free → upgrade from settings / feature gatesWizard only
HardFinish onboarding → must checkout → then dashboardWizard + choose-plan layout gate

Use soft when Free drives activation. Use hard when the product is paid-only (or trial-only via your billing provider).

For gating individual features after users are inside the app, use Feature-based access instead of blocking the whole dashboard.

Flow

After signup (and email verification when enabled), send new users to /dashboard/onboarding via callbackURL / redirectTo. The wizard lives in the reserved module and route below. When the last step finishes, mark onboardingCompleted on the server.

From there:

  • Soft - go straight to /dashboard. Users stay on Free until they upgrade from settings or hit a feature gate.
  • Hard - go to /dashboard/choose-plan. The app shell layout keeps redirecting Free users back here until checkout succeeds and getActivePlan() is paid.

Fill these empty slots:

  • apps/web/src/modules/onboarding/ - wizard UI, steps, and API helpers
  • apps/web/src/app/[locale]/dashboard/(user)/onboarding/ - add page.tsx here

Persist completion on the server

Client-only flags break across browsers and devices. Pick one approach.

Option A - Better Auth additional field (simplest)

Add a boolean on the user via Better Auth user.additionalFields, then expose it on the session. Exact wiring follows your Better Auth version - see Better Auth docs and regenerate / migrate the Drizzle schema after adding the field.

Conceptually:

// packages/auth - user additionalFields
onboardingCompleted: {
  type: "boolean",
  required: false,
  defaultValue: false,
  input: false, // only your server/API can set it
},

Mark complete with auth.api.updateUser (server) or a small Hono route that updates the user row after the last wizard step.

Option B - Dedicated table (richer answers)

If you store multi-step answers (role, company size, goals), add a table similar to other app schemas:

packages/db/src/schema/onboarding.ts
import { boolean, jsonb, pgTable, text, timestamp } from "drizzle-orm/pg-core";

import { generateId } from "@workspace/shared/utils";

import { user } from "./auth";

export const onboarding = pgTable("onboarding", {
  id: text("id").primaryKey().$defaultFn(generateId),
  userId: text("user_id")
    .notNull()
    .unique()
    .references(() => user.id, { onDelete: "cascade" }),
  data: jsonb("data").$type<Record<string, unknown>>().default({}),
  completed: boolean("completed").default(false).notNull(),
  createdAt: timestamp("created_at").defaultNow().notNull(),
  updatedAt: timestamp("updated_at")
    .defaultNow()
    .$onUpdate(() => new Date())
    .notNull(),
});

Export it from the schema barrel, generate a migration (pnpm --filter @workspace/db db:generate / db:migrate), and expose GET / PATCH under a Hono module with enforceAuth.

Invitees who join an existing org often should skip personal onboarding - set completed: true (or the additional field) when accepting an invitation.

Register paths

apps/web/src/config/paths.ts
dashboard: {
  user: {
    index: DASHBOARD_PREFIX,
    onboarding: `${DASHBOARD_PREFIX}/onboarding`,
    choosePlan: `${DASHBOARD_PREFIX}/choose-plan`, // hard paywall only
    ai: `${DASHBOARD_PREFIX}/ai`,
    // ...
  },
},

Use these constants everywhere - layout redirects, auth callbackURL, and wizard navigation.

Guard the dashboard with route groups

Today the user dashboard layout only checks session, then renders the sidebar shell. Split routes so onboarding never mounts that shell - this avoids pathname sniffing and redirect loops.

dashboard/(user)/
  layout.tsx                 ← session required for everything below
  onboarding/page.tsx        ← wizard (no sidebar)
  choose-plan/page.tsx       ← hard paywall only (no sidebar)
  (app)/
    layout.tsx               ← onboarding + optional plan guards + sidebar
    page.tsx                 ← home
    ai/...
    settings/...

Move the existing sidebar layout into (app)/layout.tsx. In that layout, redirect incomplete users before rendering the shell:

apps/web/src/app/[locale]/dashboard/(user)/(app)/layout.tsx
import { redirect } from "next/navigation";

import { BillingPlan, getActivePlan } from "@workspace/billing";

import { pathsConfig } from "~/config/paths";
import { getSession } from "~/lib/auth/server";
// reuse the same billing summary prefetch the kit already uses

const REQUIRE_PAID_PLAN = false; // flip to true for hard paywall

export default async function AppShellLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const { user } = await getSession();

  if (!user) {
    return redirect(pathsConfig.auth.login);
  }

  if (!user.onboardingCompleted) {
    return redirect(pathsConfig.dashboard.user.onboarding);
  }

  if (REQUIRE_PAID_PLAN) {
    const summary = /* await billing summary for user.id */;
    if (getActivePlan(summary) === BillingPlan.FREE) {
      return redirect(pathsConfig.dashboard.user.choosePlan);
    }
  }

  return /* existing SidebarProvider + children */;
}

Parent (user)/layout.tsx only needs the session check (and shared providers). Onboarding and choose-plan stay siblings of (app), so they never hit the completion/plan redirects.

Invite flows

Mark invited users as completed (or send them through a shorter path) when they accept an org invite, otherwise the (app) guard will trap them in personal onboarding.

Build the wizard UI

Create a small compound flow in the reserved module - one page, multiple steps, shared state:

apps/web/src/modules/onboarding/onboarding-wizard.tsx
"use client";

import { useState } from "react";
import { useRouter } from "next/navigation";

import { pathsConfig } from "~/config/paths";

import { ProfileStep } from "./steps/profile-step";
import { WorkspaceStep } from "./steps/workspace-step";
import { completeOnboarding } from "./lib/api";

const STEPS = ["profile", "workspace"] as const;

export const OnboardingWizard = () => {
  const router = useRouter();
  const [index, setIndex] = useState(0);
  const [answers, setAnswers] = useState<Record<string, unknown>>({});

  const finish = async () => {
    await completeOnboarding(answers);
    router.replace(pathsConfig.dashboard.user.index);
    // Hard paywall: replace with pathsConfig.dashboard.user.choosePlan
  };

  if (STEPS[index] === "profile") {
    return (
      <ProfileStep
        onNext={(data) => {
          setAnswers((prev) => ({ ...prev, ...data }));
          setIndex(1);
        }}
      />
    );
  }

  return (
    <WorkspaceStep
      onBack={() => setIndex(0)}
      onNext={async (data) => {
        setAnswers((prev) => ({ ...prev, ...data }));
        await finish();
      }}
    />
  );
};
apps/web/src/app/[locale]/dashboard/(user)/onboarding/page.tsx
import { OnboardingWizard } from "~/modules/onboarding/onboarding-wizard";

export default function OnboardingPage() {
  return (
    <div className="mx-auto flex min-h-[80vh] max-w-lg flex-col justify-center gap-8 px-4">
      <OnboardingWizard />
    </div>
  );
}

Wire completeOnboarding to your Option A/B mutation. Keep steps as named exports under modules/onboarding/steps/ so the page stays thin.

Add progress UI (dots or a stepper) in the module - mirror the mobile dots pattern if you want a consistent brand across platforms.

Send new users into onboarding after auth

Default post-login land is /dashboard. For new accounts, prefer onboarding.

Where login / register pass callbackURL or redirectTo (auth forms under apps/web/src/modules/auth/), use onboarding path after email verification and social callbacks for first-time users. Returning users with onboardingCompleted should keep pathsConfig.dashboard.user.index.

If the layout guard is in place, even a dashboard deep-link will bounce incomplete users to onboarding - setting callbackURL correctly just avoids a flash.

Optional: hard paywall with choose-plan

Choose-plan page

Reuse pricing / plan cards from the marketing or billing modules. On select, call the same checkout mutation the pricing page uses (billing.mutations / provider checkout). Return URL:

successUrl: pathsConfig.dashboard.user.index,
cancelUrl: pathsConfig.dashboard.user.choosePlan,

Layout gate

Set REQUIRE_PAID_PLAN = true in the layout sketch above. Users who finish onboarding but stay on Free keep landing on choose-plan until webhooks mark a subscription or order active.

Trials

If your provider grants a trial, getActivePlan() should already resolve to the paid plan while status is TRIALING (active statuses include trial). Do not invent a second trial flag unless product needs it.

Org billing

When subscriptions hang on organizations, gate on activeOrganizationId + that org’s billing summary instead of the user id - same referenceId rules as feature-based access.

Verify the matrix

ScenarioExpected
New signupLands on onboarding; cannot open /dashboard home
Completes wizard (soft)Dashboard home; Free plan OK
Completes wizard (hard)Choose-plan until checkout succeeds
Returning user, completedDashboard; no onboarding flash
Invite acceptSkip or shortened onboarding
Checkout webhook delayChoose-plan can poll billing.queries.summary or show “confirming payment…”

Checklist

  • Completion stored on the server (field or table)
  • Paths for onboarding (+ choosePlan if hard)
  • Layout redirects without loops
  • Wizard UI under modules/onboarding
  • Auth callbackURL for new users
  • Hard paywall only if product requires paid access; otherwise use feature gates
  • API still enforces paid capabilities

Other platforms

Mobile already ships welcome + steps + paywall - customize that flow instead of copying this web structure 1:1.

How is this guide?

Last updated on

On this page

Ship your startup everywhere. In minutes.Try TurboStarter