Back to Blog

SaaS Onboarding Checklist: Interactive UI, Progress Tracking, Completion Rewards, and Analytics

Build a SaaS onboarding checklist that drives activation: interactive step UI with progress tracking, server-side completion state, completion rewards with Stripe, and funnel analytics to identify drop-off points.

Viprasol Tech Team
November 24, 2026
13 min read

Activation โ€” getting a user to their "aha moment" โ€” is the most important metric in the first 14 days. Industry benchmarks put SaaS activation rates at 20โ€“40%; best-in-class products hit 60%+. An onboarding checklist is the single most effective activation lever: it makes the path to value explicit, creates progress momentum, and gives you data on exactly where users drop off.

This post covers the full implementation: database schema, checklist step definition, server-side completion tracking, an interactive React UI with progress animation, completion rewards, and SQL analytics to identify the most valuable and most-abandoned steps.

Checklist Architecture

User signs up
      โ”‚
      โ–ผ
Onboarding record created (all steps incomplete)
      โ”‚
      โ–ผ
User opens app โ†’ checklist shown in sidebar
      โ”œโ”€โ”€ Steps auto-complete when user performs actions (event-driven)
      โ””โ”€โ”€ Steps manually completed when user clicks "Mark done"
      โ”‚
      โ–ผ
All steps complete โ†’ reward issued + checklist dismissed
      โ”‚
      โ–ผ
Analytics: which steps correlate with paid conversion?

1. Database Schema

-- Define checklist templates (different plans may have different steps)
CREATE TABLE checklist_templates (
  id          UUID    PRIMARY KEY DEFAULT gen_random_uuid(),
  name        TEXT    NOT NULL,   -- 'free_tier', 'pro_trial', etc.
  is_active   BOOLEAN NOT NULL DEFAULT true
);

-- Steps within a template
CREATE TABLE checklist_steps (
  id              UUID    PRIMARY KEY DEFAULT gen_random_uuid(),
  template_id     UUID    NOT NULL REFERENCES checklist_templates(id),
  step_key        TEXT    NOT NULL,   -- 'connect_integration', 'invite_teammate', etc.
  title           TEXT    NOT NULL,
  description     TEXT    NOT NULL,
  cta_label       TEXT    NOT NULL,   -- 'Connect now', 'Invite now'
  cta_url         TEXT,              -- Route to navigate to on click
  display_order   INTEGER NOT NULL,
  is_required     BOOLEAN NOT NULL DEFAULT true,
  auto_complete   BOOLEAN NOT NULL DEFAULT false, -- Completed by event, not user click
  
  UNIQUE (template_id, step_key)
);

-- Per-user checklist instance
CREATE TABLE user_checklists (
  id              UUID        PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id         UUID        NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
  template_id     UUID        NOT NULL REFERENCES checklist_templates(id),
  
  completed_at    TIMESTAMPTZ,     -- NULL = not fully complete
  dismissed_at    TIMESTAMPTZ,     -- User hid it without completing
  reward_issued   BOOLEAN     NOT NULL DEFAULT false,
  
  created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Per-step completion record
CREATE TABLE user_checklist_steps (
  id              UUID        PRIMARY KEY DEFAULT gen_random_uuid(),
  checklist_id    UUID        NOT NULL REFERENCES user_checklists(id) ON DELETE CASCADE,
  step_id         UUID        NOT NULL REFERENCES checklist_steps(id),
  
  is_complete     BOOLEAN     NOT NULL DEFAULT false,
  completed_at    TIMESTAMPTZ,
  completed_via   TEXT,       -- 'auto' | 'manual' | 'admin'
  
  -- Time spent on this step (for analytics)
  first_viewed_at TIMESTAMPTZ,
  view_count      INTEGER     NOT NULL DEFAULT 0,
  
  UNIQUE (checklist_id, step_id),
  INDEX idx_checklist_steps_checklist (checklist_id, is_complete)
);

๐Ÿš€ SaaS MVP in 8 Weeks โ€” Seriously

We have launched 50+ SaaS platforms. Multi-tenant architecture, Stripe billing, auth, role-based access, and cloud deployment โ€” all handled by one senior team.

  • Week 1โ€“2: Architecture design + wireframes
  • Week 3โ€“6: Core features built + tested
  • Week 7โ€“8: Launch-ready on AWS/Vercel with CI/CD
  • Post-launch: Maintenance plans from month 3

2. Checklist Service

// src/services/onboarding/checklist.service.ts
import { db } from '../../lib/db';

export interface ChecklistStep {
  stepKey: string;
  title: string;
  description: string;
  ctaLabel: string;
  ctaUrl: string | null;
  displayOrder: number;
  isRequired: boolean;
  isComplete: boolean;
  completedAt: Date | null;
}

export interface UserChecklist {
  id: string;
  totalSteps: number;
  completedSteps: number;
  progressPercent: number;
  isFullyComplete: boolean;
  steps: ChecklistStep[];
}

// Get or initialize a user's checklist
export async function getUserChecklist(userId: string): Promise<UserChecklist> {
  // Find existing or create new
  let checklist = await db.userChecklist.findUnique({
    where: { userId },
    include: {
      userChecklistSteps: { include: { step: true }, orderBy: { step: { displayOrder: 'asc' } } },
      template: { include: { checklistSteps: { orderBy: { displayOrder: 'asc' } } } },
    },
  });

  if (!checklist) {
    checklist = await initializeChecklist(userId);
  }

  const steps: ChecklistStep[] = checklist.template.checklistSteps.map((step) => {
    const userStep = checklist!.userChecklistSteps.find((us) => us.stepId === step.id);
    return {
      stepKey: step.stepKey,
      title: step.title,
      description: step.description,
      ctaLabel: step.ctaLabel,
      ctaUrl: step.ctaUrl,
      displayOrder: step.displayOrder,
      isRequired: step.isRequired,
      isComplete: userStep?.isComplete ?? false,
      completedAt: userStep?.completedAt ?? null,
    };
  });

  const totalSteps = steps.filter((s) => s.isRequired).length;
  const completedSteps = steps.filter((s) => s.isRequired && s.isComplete).length;

  return {
    id: checklist.id,
    totalSteps,
    completedSteps,
    progressPercent: totalSteps > 0 ? Math.round((completedSteps / totalSteps) * 100) : 0,
    isFullyComplete: completedSteps === totalSteps,
    steps,
  };
}

async function initializeChecklist(userId: string) {
  const user = await db.user.findUniqueOrThrow({
    where: { id: userId },
    select: { plan: true },
  });

  const template = await db.checklistTemplate.findFirstOrThrow({
    where: { name: user.plan === 'pro' ? 'pro_trial' : 'free_tier', isActive: true },
    include: { checklistSteps: true },
  });

  return db.userChecklist.create({
    data: {
      userId,
      templateId: template.id,
      userChecklistSteps: {
        create: template.checklistSteps.map((step) => ({ stepId: step.id })),
      },
    },
    include: {
      userChecklistSteps: { include: { step: true } },
      template: { include: { checklistSteps: true } },
    },
  });
}

// Complete a step (called from app events OR manual click)
export async function completeChecklistStep(
  userId: string,
  stepKey: string,
  via: 'auto' | 'manual' | 'admin' = 'manual'
): Promise<{ wasNewCompletion: boolean; checklistComplete: boolean }> {
  const checklist = await db.userChecklist.findUnique({
    where: { userId },
    include: {
      userChecklistSteps: { include: { step: true } },
    },
  });

  if (!checklist || checklist.completedAt) return { wasNewCompletion: false, checklistComplete: true };

  const userStep = checklist.userChecklistSteps.find((s) => s.step.stepKey === stepKey);
  if (!userStep || userStep.isComplete) return { wasNewCompletion: false, checklistComplete: false };

  await db.userChecklistStep.update({
    where: { id: userStep.id },
    data: { isComplete: true, completedAt: new Date(), completedVia: via },
  });

  // Check if checklist is now fully complete
  const allSteps = await db.userChecklistStep.findMany({
    where: { checklistId: checklist.id },
    include: { step: true },
  });

  const allRequiredComplete = allSteps
    .filter((s) => s.step.isRequired)
    .every((s) => s.isComplete || s.stepId === userStep.stepId);

  if (allRequiredComplete) {
    await db.userChecklist.update({
      where: { id: checklist.id },
      data: { completedAt: new Date() },
    });
    // Issue reward asynchronously
    issueCompletionReward(userId).catch(console.error);
  }

  return { wasNewCompletion: true, checklistComplete: allRequiredComplete };
}

// Issue reward when checklist completed (extend trial, add credits)
async function issueCompletionReward(userId: string): Promise<void> {
  const checklist = await db.userChecklist.findUnique({ where: { userId } });
  if (!checklist || checklist.rewardIssued) return;

  const user = await db.user.findUniqueOrThrow({
    where: { id: userId },
    include: { account: { include: { subscription: true } } },
  });

  // Extend trial by 7 days as reward
  if (user.account?.subscription?.stripeCustomerId) {
    const { stripe } = await import('../../lib/stripe');
    await stripe.customers.createBalanceTransaction(
      user.account.subscription.stripeCustomerId,
      {
        amount: -1000, // $10 credit
        currency: 'usd',
        description: 'Onboarding completion reward',
        metadata: { userId, reason: 'checklist_complete' },
      }
    );
  }

  await db.userChecklist.update({
    where: { userId },
    data: { rewardIssued: true },
  });
}

3. React Checklist UI

// src/components/onboarding/ChecklistWidget.tsx
'use client';

import { useState } from 'react';
import { CheckCircle2, Circle, ChevronDown, ChevronUp, X, ArrowRight } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { cn } from '../../lib/utils';
import type { UserChecklist, ChecklistStep } from '../../services/onboarding/checklist.service';

export function ChecklistWidget() {
  const router = useRouter();
  const queryClient = useQueryClient();
  const [isExpanded, setIsExpanded] = useState(true);

  const { data: checklist, isLoading } = useQuery({
    queryKey: ['onboarding-checklist'],
    queryFn: () => fetch('/api/onboarding/checklist').then((r) => r.json() as Promise<UserChecklist>),
  });

  const completeStepMutation = useMutation({
    mutationFn: (stepKey: string) =>
      fetch(`/api/onboarding/checklist/${stepKey}/complete`, { method: 'POST' }),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['onboarding-checklist'] });
    },
  });

  const dismissMutation = useMutation({
    mutationFn: () =>
      fetch('/api/onboarding/checklist/dismiss', { method: 'POST' }),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['onboarding-checklist'] });
    },
  });

  if (isLoading || !checklist) return null;
  if (checklist.isFullyComplete) return <CompletionBanner />;

  return (
    <div className="rounded-xl border border-gray-200 bg-white shadow-sm overflow-hidden">
      {/* Header */}
      <div className="flex items-center justify-between px-4 py-3 bg-gray-50 border-b border-gray-100">
        <button
          onClick={() => setIsExpanded(!isExpanded)}
          className="flex items-center gap-2 flex-1"
        >
          <span className="font-semibold text-gray-900 text-sm">
            Get started ({checklist.completedSteps}/{checklist.totalSteps})
          </span>
          {isExpanded ? (
            <ChevronUp className="w-4 h-4 text-gray-500" />
          ) : (
            <ChevronDown className="w-4 h-4 text-gray-500" />
          )}
        </button>
        <button
          onClick={() => dismissMutation.mutate()}
          className="text-gray-400 hover:text-gray-600 ml-2"
          aria-label="Dismiss checklist"
        >
          <X className="w-4 h-4" />
        </button>
      </div>

      {/* Progress bar */}
      <div className="h-1.5 bg-gray-100">
        <div
          className="h-full bg-blue-500 transition-all duration-500 ease-out"
          style={{ width: `${checklist.progressPercent}%` }}
        />
      </div>

      {/* Steps */}
      {isExpanded && (
        <div className="divide-y divide-gray-50">
          {checklist.steps.map((step) => (
            <ChecklistStepRow
              key={step.stepKey}
              step={step}
              onComplete={() => completeStepMutation.mutate(step.stepKey)}
              onNavigate={() => {
                if (step.ctaUrl) router.push(step.ctaUrl);
              }}
            />
          ))}
        </div>
      )}

      {/* Footer */}
      {isExpanded && (
        <div className="px-4 py-3 bg-blue-50 border-t border-blue-100">
          <p className="text-xs text-blue-700">
            ๐ŸŽ Complete all steps to earn a $10 credit on your account
          </p>
        </div>
      )}
    </div>
  );
}

function ChecklistStepRow({
  step,
  onComplete,
  onNavigate,
}: {
  step: ChecklistStep;
  onComplete: () => void;
  onNavigate: () => void;
}) {
  const [isHovered, setIsHovered] = useState(false);

  return (
    <div
      className={cn(
        'flex items-start gap-3 px-4 py-3 transition-colors',
        step.isComplete ? 'opacity-60' : 'hover:bg-gray-50'
      )}
      onMouseEnter={() => setIsHovered(true)}
      onMouseLeave={() => setIsHovered(false)}
    >
      <button
        onClick={!step.isComplete ? onComplete : undefined}
        disabled={step.isComplete}
        className="mt-0.5 flex-shrink-0"
      >
        {step.isComplete ? (
          <CheckCircle2 className="w-5 h-5 text-green-500" />
        ) : (
          <Circle className={cn('w-5 h-5', isHovered ? 'text-blue-400' : 'text-gray-300')} />
        )}
      </button>

      <div className="flex-1 min-w-0">
        <p className={cn('text-sm font-medium', step.isComplete ? 'line-through text-gray-400' : 'text-gray-900')}>
          {step.title}
        </p>
        {!step.isComplete && (
          <p className="text-xs text-gray-500 mt-0.5">{step.description}</p>
        )}
      </div>

      {!step.isComplete && step.ctaUrl && (
        <button
          onClick={onNavigate}
          className="flex items-center gap-1 text-xs font-medium text-blue-600 hover:text-blue-700 flex-shrink-0"
        >
          {step.ctaLabel}
          <ArrowRight className="w-3 h-3" />
        </button>
      )}
    </div>
  );
}

function CompletionBanner() {
  return (
    <div className="rounded-xl border border-green-200 bg-green-50 p-4 text-center">
      <CheckCircle2 className="w-8 h-8 text-green-500 mx-auto mb-2" />
      <p className="font-semibold text-green-900">You're all set!</p>
      <p className="text-sm text-green-700 mt-1">
        Your $10 credit has been added to your account.
      </p>
    </div>
  );
}

๐Ÿ’ก The Difference Between a SaaS Demo and a SaaS Business

Anyone can build a demo. We build SaaS products that handle real load, real users, and real payments โ€” with architecture that does not need to be rewritten at 1,000 users.

  • Multi-tenant PostgreSQL with row-level security
  • Stripe subscriptions, usage billing, annual plans
  • SOC2-ready infrastructure from day one
  • We own zero equity โ€” you own everything

4. Onboarding Analytics

-- Step drop-off: where do users stop?
SELECT
  cs.step_key,
  cs.title,
  cs.display_order,
  COUNT(ucs.id) AS total_users_reached,
  COUNT(ucs.id) FILTER (WHERE ucs.is_complete) AS completed,
  ROUND(
    100.0 * COUNT(ucs.id) FILTER (WHERE ucs.is_complete) /
    NULLIF(COUNT(ucs.id), 0), 1
  ) AS completion_rate_pct,
  AVG(
    EXTRACT(EPOCH FROM (ucs.completed_at - uc.created_at)) / 3600
  ) FILTER (WHERE ucs.is_complete) AS avg_hours_to_complete
FROM checklist_steps cs
JOIN user_checklist_steps ucs ON cs.id = ucs.step_id
JOIN user_checklists uc ON ucs.checklist_id = uc.id
WHERE uc.created_at >= NOW() - INTERVAL '90 days'
GROUP BY cs.id, cs.step_key, cs.title, cs.display_order
ORDER BY cs.display_order;

-- Correlation: checklist completion โ†’ paid conversion
SELECT
  CASE WHEN uc.completed_at IS NOT NULL THEN 'completed_checklist' ELSE 'did_not_complete' END
    AS cohort,
  COUNT(DISTINCT u.id) AS users,
  COUNT(DISTINCT s.user_id) AS converted_to_paid,
  ROUND(
    100.0 * COUNT(DISTINCT s.user_id) / NULLIF(COUNT(DISTINCT u.id), 0), 1
  ) AS conversion_rate_pct
FROM users u
LEFT JOIN user_checklists uc ON u.id = uc.user_id
LEFT JOIN subscriptions s ON u.id = s.user_id AND s.status = 'active'
WHERE u.created_at >= NOW() - INTERVAL '90 days'
GROUP BY 1;

Cost Reference

Onboarding investmentExpected activation liftPayback period
Basic checklist (eng 2โ€“3 weeks)+10โ€“15% activation1โ€“2 months
Checklist + rewards ($10 credit)+20โ€“30% activation2โ€“4 months
Personalized checklist by plan+30โ€“40% activation3โ€“6 months
Interactive product tours + checklist+40โ€“60% activation4โ€“8 months

See Also


Working With Viprasol

Building a SaaS product where users sign up but don't activate? We design and implement onboarding checklists with event-driven auto-completion, animated progress UI, completion rewards via Stripe, and the analytics to tell you exactly which steps are losing users โ€” typically driving 20โ€“35% activation lift.

Talk to our team โ†’ | Explore our SaaS engineering services โ†’

Share this article:

About the Author

V

Viprasol Tech Team

Custom Software Development Specialists

The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

Building a SaaS Product?

We've helped launch 50+ SaaS platforms. Let's build yours โ€” fast.

Free consultation โ€ข No commitment โ€ข Response within 24 hours

Viprasol ยท AI Agent Systems

Add AI automation to your SaaS product?

Viprasol builds custom AI agent crews that plug into any SaaS workflow โ€” automating repetitive tasks, qualifying leads, and responding across every channel your customers use.