Claude Code Boilerplate
FeaturesPricingBlogDocs
Get started →

Product

  • Features
  • Pricing
  • Skills

Compare

  • vs ShipFast
  • vs MakerKit
  • vs supastarter

Resources

  • Docs
  • Blog
  • Discord

Legal

  • License
  • Privacy Policy
  • Terms of Service
Claude Code Boilerplate

© 2026 Claude Code Boilerplate. All rights reserved.

← All posts

User onboarding checklist in Next.js App Router -- tracking per-user setup progress with Drizzle ORM

June 23, 2026
nextjsdrizzle-ormsaasneon-db

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.

The schema

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.

The repository

// 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.

The service and route

// 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.

The server component

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.

Seeding the steps

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.

Triggering steps automatically

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.

What you get

  • A checklist that persists across sessions and devices
  • Idempotent step completion -- safe to call from any service
  • A server component that renders in the initial HTML with no loading state
  • Auto-dismiss once every step is complete

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.