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.
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 investment | Expected activation lift | Payback period |
|---|---|---|
| Basic checklist (eng 2โ3 weeks) | +10โ15% activation | 1โ2 months |
| Checklist + rewards ($10 credit) | +20โ30% activation | 2โ4 months |
| Personalized checklist by plan | +30โ40% activation | 3โ6 months |
| Interactive product tours + checklist | +40โ60% activation | 4โ8 months |
See Also
- SaaS Trial Conversion: Onboarding Sequences and Feature Gates
- SaaS Email Sequences: Transactional System and Drip Campaigns
- SaaS Referral System: Tracking, Reward Logic, and Analytics
- Stripe Webhook Handling: Signature Verification and Idempotency
- Product Analytics Engineering: Tracking, Funnels, and Retention
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 โ
About the Author
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.
Building a SaaS Product?
We've helped launch 50+ SaaS platforms. Let's build yours โ fast.
Free consultation โข No commitment โข Response within 24 hours
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.