SaaS Onboarding Flow: Progress Tracking, Guided Setup, and Activation Metrics
Build a SaaS onboarding flow that activates users. Covers checklist design, progress tracking with PostgreSQL, contextual guidance tooltips, activation event detection, and onboarding analytics.
Activation is the most important metric in SaaS. A user who signs up but never reaches the "aha moment" is a churn waiting to happen โ and most churn decisions are made in the first 48 hours. The onboarding flow is your only chance to guide someone from "curious signup" to "I understand the value and have used the core feature."
This guide builds a complete onboarding system: checklist with progress tracking, step-by-step guidance, activation event detection, and the analytics that tell you where users drop off.
The Activation Model
Before writing code, define what "activated" means for your product. A generic onboarding fails because it doesn't lead users to the specific action that creates value.
Activation = User has experienced the core value proposition
Examples:
- Project management tool: created project + added 3 tasks + invited teammate
- Analytics platform: connected data source + viewed first report
- Communication tool: sent first message to a real person
- Email tool: sent first campaign to a real list
Map your activation event before building the onboarding.
Database Schema
CREATE TYPE onboarding_step AS ENUM (
'profile_completed',
'workspace_named',
'first_project_created',
'first_task_created',
'teammate_invited',
'integration_connected',
'first_export',
'billing_added'
);
CREATE TABLE user_onboarding (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id),
completed_steps onboarding_step[] NOT NULL DEFAULT '{}',
is_activated BOOLEAN NOT NULL DEFAULT FALSE,
activated_at TIMESTAMPTZ,
dismissed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(workspace_id, user_id)
);
CREATE INDEX idx_onboarding_workspace ON user_onboarding(workspace_id);
CREATE INDEX idx_onboarding_activated ON user_onboarding(is_activated, created_at) WHERE is_activated = FALSE;
model UserOnboarding {
id String @id @default(uuid())
workspaceId String
userId String
completedSteps OnboardingStep[]
isActivated Boolean @default(false)
activatedAt DateTime?
dismissedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id])
@@unique([workspaceId, userId])
@@index([workspaceId])
}
enum OnboardingStep {
PROFILE_COMPLETED
WORKSPACE_NAMED
FIRST_PROJECT_CREATED
FIRST_TASK_CREATED
TEAMMATE_INVITED
INTEGRATION_CONNECTED
FIRST_EXPORT
BILLING_ADDED
}
๐ 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
Onboarding Step Configuration
// lib/onboarding/steps.ts
import type { OnboardingStep } from "@prisma/client";
export interface OnboardingStepConfig {
id: OnboardingStep;
title: string;
description: string;
ctaLabel: string;
ctaHref: string;
estimatedMinutes: number;
isRequired: boolean; // Required for activation
icon: string;
}
export const ONBOARDING_STEPS: OnboardingStepConfig[] = [
{
id: "PROFILE_COMPLETED",
title: "Complete your profile",
description: "Add your name and photo so teammates can recognise you.",
ctaLabel: "Set up profile",
ctaHref: "/settings/profile",
estimatedMinutes: 1,
isRequired: false,
icon: "๐ค",
},
{
id: "WORKSPACE_NAMED",
title: "Name your workspace",
description: "Give your workspace a name that matches your team or project.",
ctaLabel: "Name workspace",
ctaHref: "/settings/workspace",
estimatedMinutes: 1,
isRequired: true,
icon: "๐ ",
},
{
id: "FIRST_PROJECT_CREATED",
title: "Create your first project",
description: "Projects organise your work. Start with one real project.",
ctaLabel: "Create project",
ctaHref: "/projects/new",
estimatedMinutes: 2,
isRequired: true,
icon: "๐",
},
{
id: "FIRST_TASK_CREATED",
title: "Add your first task",
description: "Break your project into tasks. Add at least one to get started.",
ctaLabel: "Add task",
ctaHref: "/projects",
estimatedMinutes: 2,
isRequired: true,
icon: "โ
",
},
{
id: "TEAMMATE_INVITED",
title: "Invite a teammate",
description: "Work is better together. Invite someone from your team.",
ctaLabel: "Invite teammate",
ctaHref: "/settings/members/invite",
estimatedMinutes: 1,
isRequired: true,
icon: "๐ฅ",
},
{
id: "INTEGRATION_CONNECTED",
title: "Connect an integration",
description: "Connect Slack, GitHub, or another tool your team uses.",
ctaLabel: "Browse integrations",
ctaHref: "/settings/integrations",
estimatedMinutes: 3,
isRequired: false,
icon: "๐",
},
];
// Steps required for activation (the "aha moment" minimum path)
export const ACTIVATION_STEPS: OnboardingStep[] = ONBOARDING_STEPS
.filter((s) => s.isRequired)
.map((s) => s.id);
export function isActivated(completedSteps: OnboardingStep[]): boolean {
return ACTIVATION_STEPS.every((step) => completedSteps.includes(step));
}
export function getProgress(completedSteps: OnboardingStep[]): {
completed: number;
total: number;
percentage: number;
nextStep: OnboardingStepConfig | null;
} {
const completed = completedSteps.length;
const total = ONBOARDING_STEPS.length;
const percentage = Math.round((completed / total) * 100);
const nextStep =
ONBOARDING_STEPS.find((s) => !completedSteps.includes(s.id)) ?? null;
return { completed, total, percentage, nextStep };
}
Onboarding Service
// lib/onboarding/service.ts
import { prisma } from "@/lib/prisma";
import { isActivated, ACTIVATION_STEPS } from "./steps";
import type { OnboardingStep } from "@prisma/client";
export async function getOrCreateOnboarding(
workspaceId: string,
userId: string
) {
return prisma.userOnboarding.upsert({
where: { workspaceId_userId: { workspaceId, userId } },
create: { workspaceId, userId },
update: {},
});
}
export async function completeStep(
workspaceId: string,
userId: string,
step: OnboardingStep
): Promise<{ isActivated: boolean; wasNewStep: boolean }> {
const onboarding = await getOrCreateOnboarding(workspaceId, userId);
if (onboarding.completedSteps.includes(step)) {
return { isActivated: onboarding.isActivated, wasNewStep: false };
}
const newSteps = [...onboarding.completedSteps, step];
const activated = isActivated(newSteps);
await prisma.userOnboarding.update({
where: { id: onboarding.id },
data: {
completedSteps: { push: step },
...(activated && !onboarding.isActivated
? { isActivated: true, activatedAt: new Date() }
: {}),
},
});
// Track activation event for analytics
if (activated && !onboarding.isActivated) {
await trackActivation(workspaceId, userId);
}
return { isActivated: activated, wasNewStep: true };
}
async function trackActivation(
workspaceId: string,
userId: string
): Promise<void> {
// Send to your analytics pipeline
await prisma.analyticsEvent.create({
data: {
workspaceId,
userId,
eventType: "user_activated",
metadata: {
timestamp: new Date().toISOString(),
},
},
});
// Could also: send to Segment, Mixpanel, PostHog, etc.
}
๐ก 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
Automatic Step Detection
Detect completed steps from domain events rather than requiring explicit calls:
// lib/onboarding/detectors.ts
import { completeStep } from "./service";
// Call this whenever a project is created (in your project creation logic)
export async function onProjectCreated(
workspaceId: string,
createdByUserId: string
): Promise<void> {
await completeStep(workspaceId, createdByUserId, "FIRST_PROJECT_CREATED");
}
// Call from task creation handler
export async function onTaskCreated(
workspaceId: string,
createdByUserId: string
): Promise<void> {
await completeStep(workspaceId, createdByUserId, "FIRST_TASK_CREATED");
}
// Call from invitation sent handler
export async function onInviteSent(
workspaceId: string,
invitedByUserId: string
): Promise<void> {
await completeStep(workspaceId, invitedByUserId, "TEAMMATE_INVITED");
}
// Call from profile update handler
export async function onProfileUpdated(
workspaceId: string,
userId: string,
hasName: boolean,
hasAvatar: boolean
): Promise<void> {
if (hasName) {
await completeStep(workspaceId, userId, "PROFILE_COMPLETED");
}
}
// Call from workspace settings update
export async function onWorkspaceNamed(
workspaceId: string,
updatedByUserId: string
): Promise<void> {
await completeStep(workspaceId, updatedByUserId, "WORKSPACE_NAMED");
}
Onboarding Checklist Component
// components/onboarding/checklist.tsx
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { CheckCircle2, Circle, ChevronRight, X, Clock } from "lucide-react";
import { ONBOARDING_STEPS, getProgress } from "@/lib/onboarding/steps";
import type { OnboardingStep } from "@prisma/client";
interface OnboardingChecklistProps {
completedSteps: OnboardingStep[];
onDismiss: () => void;
}
export function OnboardingChecklist({
completedSteps,
onDismiss,
}: OnboardingChecklistProps) {
const [isExpanded, setIsExpanded] = useState(true);
const router = useRouter();
const { completed, total, percentage, nextStep } = getProgress(completedSteps);
const totalMinutes = ONBOARDING_STEPS
.filter((s) => !completedSteps.includes(s.id))
.reduce((sum, s) => sum + s.estimatedMinutes, 0);
return (
<div className="bg-white border border-gray-200 rounded-xl shadow-sm overflow-hidden">
{/* Header */}
<div
className="flex items-center justify-between px-4 py-3 cursor-pointer hover:bg-gray-50"
onClick={() => setIsExpanded((e) => !e)}
>
<div className="flex items-center gap-3">
<div className="relative w-8 h-8">
<svg className="w-8 h-8 -rotate-90" viewBox="0 0 32 32">
<circle cx="16" cy="16" r="12" fill="none" stroke="#e5e7eb" strokeWidth="3" />
<circle
cx="16" cy="16" r="12"
fill="none"
stroke="#3b82f6"
strokeWidth="3"
strokeDasharray={`${2 * Math.PI * 12}`}
strokeDashoffset={`${2 * Math.PI * 12 * (1 - percentage / 100)}`}
className="transition-all duration-500"
/>
</svg>
<span className="absolute inset-0 flex items-center justify-center text-[10px] font-bold text-gray-700">
{percentage}%
</span>
</div>
<div>
<p className="text-sm font-semibold text-gray-900">
Get started ยท {completed}/{total} done
</p>
{totalMinutes > 0 && (
<p className="text-xs text-gray-500 flex items-center gap-1">
<Clock className="w-3 h-3" />
~{totalMinutes} min remaining
</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={(e) => {
e.stopPropagation();
onDismiss();
}}
className="p-1 text-gray-400 hover:text-gray-600 rounded"
aria-label="Dismiss onboarding"
>
<X className="w-4 h-4" />
</button>
<ChevronRight
className={`w-4 h-4 text-gray-400 transition-transform ${
isExpanded ? "rotate-90" : ""
}`}
/>
</div>
</div>
{/* Steps list */}
{isExpanded && (
<div className="border-t border-gray-100 divide-y divide-gray-50">
{ONBOARDING_STEPS.map((step) => {
const isComplete = completedSteps.includes(step.id);
const isNext = nextStep?.id === step.id;
return (
<div
key={step.id}
onClick={() => !isComplete && router.push(step.ctaHref)}
className={`flex items-start gap-3 px-4 py-3 transition-colors ${
isComplete
? "opacity-60"
: isNext
? "bg-blue-50/60 cursor-pointer hover:bg-blue-50"
: "cursor-pointer hover:bg-gray-50"
}`}
>
{/* Icon */}
<div className="flex-shrink-0 mt-0.5">
{isComplete ? (
<CheckCircle2 className="w-5 h-5 text-green-500" />
) : (
<Circle
className={`w-5 h-5 ${
isNext ? "text-blue-500" : "text-gray-300"
}`}
/>
)}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<p
className={`text-sm font-medium ${
isComplete
? "text-gray-500 line-through"
: "text-gray-900"
}`}
>
{step.icon} {step.title}
</p>
{!isComplete && (
<p className="text-xs text-gray-500 mt-0.5">
{step.description}
</p>
)}
</div>
{/* CTA arrow */}
{!isComplete && (
<ChevronRight className="flex-shrink-0 w-4 h-4 text-gray-400 mt-0.5" />
)}
</div>
);
})}
</div>
)}
{/* Completion state */}
{completed === total && (
<div className="border-t border-gray-100 px-4 py-3 bg-green-50">
<p className="text-sm font-medium text-green-700 text-center">
๐ You're all set! Your workspace is ready.
</p>
</div>
)}
</div>
);
}
Dashboard Integration
// app/dashboard/page.tsx (Server Component)
import { auth } from "@/auth";
import { getOrCreateOnboarding } from "@/lib/onboarding/service";
import { DismissableChecklist } from "@/components/onboarding/dismissable-checklist";
export default async function DashboardPage() {
const session = await auth();
if (!session?.user) redirect("/auth/signin");
const onboarding = await getOrCreateOnboarding(
session.user.organizationId,
session.user.id
);
return (
<div className="max-w-7xl mx-auto px-4 py-8">
{/* Show checklist until activated or dismissed */}
{!onboarding.isActivated && !onboarding.dismissedAt && (
<div className="mb-6 max-w-sm">
<DismissableChecklist
completedSteps={onboarding.completedSteps}
workspaceId={session.user.organizationId}
userId={session.user.id}
/>
</div>
)}
{/* Rest of dashboard */}
</div>
);
}
Activation Funnel Analytics
-- Onboarding funnel drop-off by step
WITH step_order AS (
SELECT
unnest(ARRAY[
'WORKSPACE_NAMED',
'FIRST_PROJECT_CREATED',
'FIRST_TASK_CREATED',
'TEAMMATE_INVITED'
]::onboarding_step[]) AS step,
generate_subscripts(ARRAY[
'WORKSPACE_NAMED',
'FIRST_PROJECT_CREATED',
'FIRST_TASK_CREATED',
'TEAMMATE_INVITED'
]::onboarding_step[], 1) AS step_num
),
cohort AS (
SELECT user_id, completed_steps, created_at
FROM user_onboarding
WHERE created_at > NOW() - INTERVAL '30 days'
)
SELECT
so.step,
so.step_num,
COUNT(*) FILTER (
WHERE so.step = ANY(c.completed_steps)
) AS completed_count,
COUNT(*) AS total_signups,
ROUND(100.0 * COUNT(*) FILTER (
WHERE so.step = ANY(c.completed_steps)
) / COUNT(*), 1) AS completion_pct
FROM step_order so
CROSS JOIN cohort c
GROUP BY so.step, so.step_num
ORDER BY so.step_num;
-- Time to activation (median and p90)
SELECT
PERCENTILE_CONT(0.5) WITHIN GROUP (
ORDER BY EXTRACT(EPOCH FROM (activated_at - created_at)) / 3600
) AS median_hours_to_activation,
PERCENTILE_CONT(0.9) WITHIN GROUP (
ORDER BY EXTRACT(EPOCH FROM (activated_at - created_at)) / 3600
) AS p90_hours_to_activation,
COUNT(*) AS activated_users
FROM user_onboarding
WHERE is_activated = TRUE
AND created_at > NOW() - INTERVAL '90 days';
-- Weekly activation rate
SELECT
DATE_TRUNC('week', created_at) AS week,
COUNT(*) AS signups,
COUNT(*) FILTER (WHERE is_activated) AS activated,
ROUND(100.0 * COUNT(*) FILTER (WHERE is_activated) / COUNT(*), 1) AS activation_rate_pct
FROM user_onboarding
WHERE created_at > NOW() - INTERVAL '90 days'
GROUP BY 1
ORDER BY 1 DESC;
Cost and Timeline Estimates
| Scope | Team | Timeline | Cost Range |
|---|---|---|---|
| Basic onboarding checklist (static) | 1 dev | 1โ2 days | $300โ600 |
| Checklist with automatic step detection | 1 dev | 3โ5 days | $800โ1,500 |
| Full system (checklist + analytics + email nudges) | 1โ2 devs | 2โ3 weeks | $4,000โ8,000 |
| Product-led growth onboarding (personalisation, A/B) | 2โ3 devs | 4โ6 weeks | $12,000โ25,000 |
See Also
- SaaS Onboarding Checklist Implementation
- SaaS User Permissions and Role-Based Access
- SaaS Email Sequences and Drip Campaigns
- Next.js Server Actions and Forms
- SaaS Activity Feed Architecture
Working With Viprasol
Onboarding flows that convert require knowing your activation metric before writing code, then designing every step to drive users toward it. We've built onboarding systems for SaaS products across categories โ project management, analytics, developer tools โ with the analytics to measure activation rate, time-to-activation, and step drop-off.
What we deliver:
- Activation metric definition workshop
- Onboarding checklist with automatic step detection from domain events
- Progress tracking with PostgreSQL
- Funnel analytics queries and dashboard
- Email drip nudges for users stuck mid-onboarding
Talk to our team about improving your activation rate โ
Or explore our SaaS development 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.