Every SaaS has the same quiet problem: users sign up, poke around, and leave before they see the value. The fix is not a better landing page -- it is getting each user to their "aha moment" before they close the tab.
An onboarding checklist does exactly that. It shows users what to do next, tracks their progress in the database, and disappears once they are done. Here is how to build one that persists per user, loads with the page, and marks steps complete from anywhere in your codebase.
You need two tables: one for the steps your app defines, and one that records which steps each user has finished.
// modules/onboarding/onboarding.schema.ts
export const checklistStepTable = pgTable('checklist_steps', {
id: uuid('id').primaryKey().defaultRandom(),
key: text('key').notNull().unique(),
label: text('label').notNull(),
displayOrder: integer('display_order').notNull(),
});
export const userChecklistCompletionTable = pgTable(
'user_checklist_completions',
{
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id')
.notNull()
.references(() => userTable.id, { onDelete: 'cascade' }),
stepKey: text('step_key').notNull(),
completedAt: timestamp('completed_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [unique().on(t.userId, t.stepKey)]
);
The unique constraint on (userId, stepKey) means step completion is idempotent -- call it from anywhere, no duplicates.
// modules/onboarding/onboarding.repo.ts
export async function getChecklistForUser(userId: string) {
const [steps, completions] = await Promise.all([
db.select().from(checklistStepTable).orderBy(asc(checklistStepTable.displayOrder)),
db
.select()
.from(userChecklistCompletionTable)
.where(eq(userChecklistCompletionTable.userId, userId)),
]);
const completedKeys = new Set(completions.map((c) => c.stepKey));
return steps.map((step) => ({ ...step, completed: completedKeys.has(step.key) }));
}
export async function markStepComplete(userId: string, stepKey: string) {
await db
.insert(userChecklistCompletionTable)
.values({ userId, stepKey })
.onConflictDoNothing();
}
Two queries run in parallel. The onConflictDoNothing on insert makes the mark-complete operation safe to call more than once -- from a button click, from a webhook handler, from anywhere in the service layer.
// modules/onboarding/onboarding.service.ts
export async function completeStep(userId: string, stepKey: string) {
const steps = await db.select({ key: checklistStepTable.key }).from(checklistStepTable);
if (!steps.some((s) => s.key === stepKey)) throw new HttpError(400, 'Unknown step');
await markStepComplete(userId, stepKey);
}
// app/api/onboarding/[stepKey]/route.ts
export async function POST(req: Request, { params }: { params: Promise<{ stepKey: string }> }) {
const user = await getUserFromRequest(req);
const { stepKey } = await params;
await onboardingService.completeStep(user.id, stepKey);
return Response.json({ ok: true });
}
Three lines in the route. Validate identity, delegate to service, respond. Business logic stays out.
Render the checklist in a server component so it loads in the initial HTML -- no spinner, no flash of empty state.
// components/onboarding/OnboardingChecklist.tsx
export async function OnboardingChecklist({ userId }: { userId: string }) {
const steps = await onboardingService.getChecklist(userId);
if (steps.every((s) => s.completed)) return null;
return (
<Card className="p-4">
<h2 className="font-semibold mb-3">Get started</h2>
<ul className="space-y-2">
{steps.map((step) => (
<ChecklistItem key={step.key} step={step} />
))}
</ul>
</Card>
);
}
ChecklistItem is a small client component that calls the API on click and optimistically toggles the check. The parent disappears once every step returns completed: true.
Add this to your deploy script or a migration seed:
await db
.insert(checklistStepTable)
.values([
{ key: 'profile', label: 'Complete your profile', displayOrder: 1 },
{ key: 'invite', label: 'Invite a teammate', displayOrder: 2 },
{ key: 'first_project', label: 'Create your first project', displayOrder: 3 },
])
.onConflictDoNothing();
Run it on every deploy. The conflict clause makes reruns safe.
The most useful thing about storing completion in the database is that you can mark steps done from anywhere -- not just from a button. Call completeStep inside your profile service when a user fills in their name, inside your invite service when the first invite goes out. The user sees progress without lifting a finger.
// inside profileService.updateProfile()
await onboardingService.completeStep(userId, 'profile');
This is the pattern that makes onboarding feel native rather than bolted on: the checklist reflects real actions the user takes, not checkbox theater.
Adding onboarding to a new feature takes about an hour when the module pattern, schema conventions, and route structure are already in place. That is exactly what Claude Code Boilerplates is built around -- repeatable patterns that make every feature after the first one faster to ship.
Clone the repo and have your onboarding flow running before lunch -- start here.