Back to Blog

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.

Viprasol Tech Team
March 21, 2027
13 min read

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

ScopeTeamTimelineCost Range
Basic onboarding checklist (static)1 dev1โ€“2 days$300โ€“600
Checklist with automatic step detection1 dev3โ€“5 days$800โ€“1,500
Full system (checklist + analytics + email nudges)1โ€“2 devs2โ€“3 weeks$4,000โ€“8,000
Product-led growth onboarding (personalisation, A/B)2โ€“3 devs4โ€“6 weeks$12,000โ€“25,000

See Also


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.

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.