For the complete documentation index, see llms.txt. Prefer markdown by appending.mdto documentation URLs or sendingAccept: 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
- Persist
onboardingCompleted(Better Auth additional field or a small table) - not only localStorage. - Guard the dashboard layout: incomplete users →
/dashboard/onboarding. - Build steps under
modules/onboarding+ the reserved route folder. - Soft monetization: show upgrade CTAs; hard paywall: incomplete billing →
/dashboard/choose-planuntilgetActivePlan()is paid. - Point post-auth
callbackURL/redirectToat 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.
| Mode | What users can do after signup | Implementation |
|---|---|---|
| Soft | Finish onboarding → use Free → upgrade from settings / feature gates | Wizard only |
| Hard | Finish onboarding → must checkout → then dashboard | Wizard + 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 andgetActivePlan()is paid.
Fill these empty slots:
apps/web/src/modules/onboarding/- wizard UI, steps, and API helpersapps/web/src/app/[locale]/dashboard/(user)/onboarding/- addpage.tsxhere
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:
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
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:
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:
"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();
}}
/>
);
};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
| Scenario | Expected |
|---|---|
| New signup | Lands on onboarding; cannot open /dashboard home |
| Completes wizard (soft) | Dashboard home; Free plan OK |
| Completes wizard (hard) | Choose-plan until checkout succeeds |
| Returning user, completed | Dashboard; no onboarding flash |
| Invite accept | Skip or shortened onboarding |
| Checkout webhook delay | Choose-plan can poll billing.queries.summary or show “confirming payment…” |
Checklist
- Completion stored on the server (field or table)
- Paths for
onboarding(+choosePlanif hard) - Layout redirects without loops
- Wizard UI under
modules/onboarding - Auth
callbackURLfor 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