SaaS User Onboarding
Capability
Implement effective user onboarding flows that drive activation and reduce churn. Covers setup wizards, progress checklists, contextual tooltips, and tracking of key activation milestones. Focus on getting users to their "aha moment" quickly.
Use Cases
- Multi-step setup wizard after signup
- Onboarding checklist with progress tracking
- Contextual tooltips and feature tours
- Activation milestone tracking
- Re-engagement for incomplete onboarding
- Team member onboarding variations
When NOT to use this skill
This skill covers self-serve multi-step setup wizards, activation tracking, and contextual tooltips for typical SaaS apps. It is not the right fit for:
- B2B sales-led onboarding with CSM hand-off. Different flow; the wizard is replaced by scheduled implementation calls. Use a CRM workflow, not an in-app wizard.
- Mobile app onboarding (iOS / Android specific). Use platform-native onboarding patterns; the React patterns here will not translate.
- KYC / identity-verification flows. Use Persona, Sumsub, Veriff, or your processor's KYC API. Do not roll your own.
- Onboarding for embedded SDKs or developer tools. The primary onboarding surface is documentation, not a wizard. Different shape.
Patterns
Onboarding State Machine
When to use: Track user progress through onboarding steps
Implementation: Persist onboarding state with completed steps and progress.
// Onboarding state schema
const {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').references(() => users.id).unique(),
currentStep: text('current_step').notNull().default('welcome'),
completedSteps: jsonb('completed_steps').$type<string[]>().default([]),
stepData: jsonb('step_data').$type<Record<string, unknown>>().default({}),
startedAt: timestamp('started_at').defaultNow(),
completedAt: timestamp('completed_at'),
skippedAt: timestamp('skipped_at')
});
// Onboarding steps definition
const
{ id: 'welcome', title: 'Welcome', required: true },
{ id: 'profile', title: 'Complete Profile', required: true },
{ id: 'create_project', title: 'Create First Project', required: true },
{ id: 'invite_team', title: 'Invite Team Members', required: false },
{ id: 'connect_integration', title: 'Connect Integration', required: false },
{ id: 'explore_features', title: 'Explore Features', required: false }
] as const;
// Get onboarding status
async function getOnboardingStatus(userId: string) {
const state = await db.query.onboardingStates.findFirst({
where: eq(onboardingStates.userId, userId)
});
if (!state) {
// Initialize onboarding
const [newState] = await db.insert(onboardingStates)
.values({ userId })
.returning();
return formatOnboardingStatus(newState);
}
return formatOnboardingStatus(state);
}
function formatOnboardingStatus(state: OnboardingState) {
const totalRequired = ONBOARDING_STEPS.filter(s => s.required).length;
const completedRequired = state.completedSteps
.filter(stepId => ONBOARDING_STEPS.find(s => s.id === stepId)?.required)
.length;
return {
currentStep: state.currentStep,
completedSteps: state.completedSteps,
progress: Math.round((completedRequired / totalRequired) * 100),
isComplete: state.completedAt !== null,
isSkipped: state.skippedAt !== null,
steps: ONBOARDING_STEPS.map(step => ({
...step,
completed: state.completedSteps.includes(step.id),
current: state.currentStep === step.id
}))
};
}
Step Wizard Component
When to use: Guide users through multi-step setup process
Implementation: Step-by-step wizard with progress indicator and step validation.
// Onboarding wizard component
'use client';
import { useState } from 'react';
interface OnboardingWizardProps {
initialStatus: OnboardingStatus;
onComplete: () => void;
}
export function OnboardingWizard({ initialStatus, onComplete }: OnboardingWizardProps) {
const [status, setStatus] = useState(initialStatus);
const [isSubmitting, setIsSubmitting] = useState(false);
const currentStepIndex = status.steps.findIndex(s => s.current);
const currentStep = status.steps[currentStepIndex];
async function completeStep(stepData?: Record<string, unknown>) {
setIsSubmitting(true);
const response = await fetch('/api/onboarding/complete-step', {
method: 'POST',
body: JSON.stringify({
stepId: currentStep.id,
data: stepData
})
});
const newStatus = await response.json();
setStatus(newStatus);
setIsSubmitting(false);
if (newStatus.isComplete) {
onComplete();
}
}
async function skipOnboarding() {
await fetch('/api/onboarding/skip', { method: 'POST' });
onComplete();
}
return (
<div className="max-w-2xl mx-auto p-8">
{/* Progress bar */}
<div className="mb-8">
<div className="flex justify-between mb-2">
<span className="text-sm font-medium">Setup Progress</span>
<span className="text-sm text-gray-500">{status.progress}%</span>
</div>
<div className="h-2 bg-gray-200 rounded-full">
<div
className="h-2 bg-blue-600 rounded-full transition-all"
style={{ width: `${status.progress}%` }}
/>
</div>
</div>
{/* Step indicators */}
<div className="flex gap-2 mb-8">
{status.steps.filter(s => s.required).map((step, i) => (
<div
key={step.id}
className={`flex-1 h-1 rounded ${
step.completed ? 'bg-green-500' :
step.current ? 'bg-blue-500' : 'bg-gray-200'
}`}
/>
))}
</div>
{/* Current step content */}
<OnboardingStepContent
step={currentStep}
isSubmitting={isSubmitting}
/>
{/* Skip option for optional steps */}
{!currentStep.required && (
<button
=> completeStep()}
className="text-sm text-gray-500 mt-4"
>
Skip this step
</button>
)}
{/* Skip all option */}
<button
className="text-sm text-gray-400 mt-8 block"
>
Skip setup, I'll explore on my own
</button>
</div>
);
}
// Individual step content
function OnboardingStepContent({ step, onComplete, isSubmitting }) {
switch (step.id) {
case 'welcome':
return <WelcomeStep />;
case 'profile':
return <ProfileStep isSubmitting={isSubmitting} />;
case 'create_project':
return <CreateProjectStep isSubmitting={isSubmitting} />;
case 'invite_team':
return <InviteTeamStep isSubmitting={isSubmitting} />;
default:
return null;
}
}
Activation Tracking
When to use: Measure and optimize time-to-value
Implementation: Track key activation events and milestones.
// Activation events
const ACTIVATION_EVENTS = {
signed_up: { weight: 1, milestone: false },
completed_profile: { weight: 1, milestone: false },
created_first_project: { weight: 3, milestone: true },
invited_team_member: { weight: 2, milestone: true },
used_core_feature: { weight: 3, milestone: true }, // "Aha moment"
upgraded_plan: { weight: 5, milestone: true }
} as const;
// Track activation event
async function trackActivation(userId: string, event: string, metadata?: Record<string, unknown>) {
const eventConfig = ACTIVATION_EVENTS[event];
if (!eventConfig) return;
// Store event
await db.insert(activationEvents).values({
userId,
event,
metadata,
weight: eventConfig.weight,
createdAt: new Date()
});
// Check if this completes activation
if (eventConfig.milestone) {
await checkActivationComplete(userId);
}
// Analytics
await analytics.track(userId, 'activation_event', { event, ...metadata });
}
// Check if user is "activated"
async function checkActivationComplete(userId: string) {
const events = await db.query.activationEvents.findMany({
where: eq(activationEvents.userId, userId)
});
const completedMilestones = events
.filter(e => ACTIVATION_EVENTS[e.event]?.milestone)
.map(e => e.event);
// Activated = used core feature (aha moment)
const isActivated = completedMilestones.includes('used_core_feature');
if (isActivated) {
await db.update(users)
.set({ activatedAt: new Date() })
.where(eq(users.id, userId));
// Trigger post-activation flow
await triggerActivationCelebration(userId);
}
return isActivated;
}
// API endpoint for tracking
app.post('/api/track/activation', async (req, res) => {
const { event, metadata } = req.body;
await trackActivation(req.userId, event, metadata);
res.json({ success: true });
});
Contextual Tooltips
When to use: Highlight features as users navigate the app
Implementation: Show tooltips on first visit to features.
// Tooltip state hook
function useFeatureTooltip(featureId: string) {
const [dismissed, setDismissed] = useState(false);
useEffect(() => {
const seenFeatures = JSON.parse(
localStorage.getItem('seen_features') || '[]'
);
if (seenFeatures.includes(featureId)) {
setDismissed(true);
}
}, [featureId]);
const dismiss = useCallback(() => {
const seenFeatures = JSON.parse(
localStorage.getItem('seen_features') || '[]'
);
localStorage.setItem(
'seen_features',
JSON.stringify([...seenFeatures, featureId])
);
setDismissed(true);
// Track feature discovery
fetch('/api/track/activation', {
method: 'POST',
body: JSON.stringify({ event: 'discovered_feature', metadata: { featureId } })
});
}, [featureId]);
return { show: !dismissed, dismiss };
}
// Feature tooltip component
function FeatureTooltip({ featureId, title, description, children }) {
const { show, dismiss } = useFeatureTooltip(featureId);
if (!show) return children;
return (
<div className="relative">
{children}
<div className="absolute top-full left-0 mt-2 p-4 bg-blue-600 text-white rounded-lg shadow-lg z-50 w-64">
<button className="absolute top-2 right-2 text-white/60">×</button>
<h4 className="font-semibold mb-1">{title}</h4>
<p className="text-sm text-white/80">{description}</p>
<button className="mt-3 text-sm font-medium">Got it</button>
</div>
</div>
);
}
// Usage
<FeatureTooltip
featureId="analytics-dashboard"
title="Your Analytics Dashboard"
description="Track your key metrics here. Click any chart to dive deeper."
>
<AnalyticsDashboard />
</FeatureTooltip>
Stack Implementations
{{stack.frontend.framework}} Integration
Route Protection:
// Middleware to redirect incomplete onboarding
export async function middleware(request: NextRequest) {
const session = await getSession(request);
if (!session) return NextResponse.redirect('/login');
const getOnboardingStatus(session.userId);
// Redirect to onboarding if incomplete (except for onboarding routes)
if (!onboarding.isComplete && !onboarding.isSkipped) {
if (!request.nextUrl.pathname.startsWith('/onboarding')) {
return NextResponse.redirect('/onboarding');
}
}
return NextResponse.next();
}
Exit Criteria
Before declaring this skill's work complete, run each check below and paste the output. "Looks right" is not sufficient. Items marked gateable can be wired into project/gates/ for automated phase-exit checks.
| # | Check | Verification | Pass condition | Gateable |
|---|---|---|---|---|
| 1 | Onboarding state persisted to DB, not localStorage | grep -rEn "localStorage.*onboarding" --include="*.ts" --include="*.js" --include="*.tsx" --include="*.jsx" . |
Zero matches (state in DB; localStorage acceptable only for tooltips/transient). | yes |
| 2 | Progress visible to user | Read wizard component. | Renders a progress bar or step indicator before each step. | manual |
| 3 | Optional steps marked optional | grep -rEn "required:.*true" --include="*.ts" --include="*.js" --include="*.tsx" . (in onboarding config) |
Only steps that genuinely block product use are required: true. |
manual |
| 4 | Skip path exists and works | Functional test: signup → click "Skip onboarding" → land on dashboard with isSkipped true. |
Dashboard accessible; user not redirected back into wizard. | yes |
| 5 | App access not blocked by incomplete onboarding | Read route middleware for app routes. | Middleware nudges, does not block, except for genuinely-required-first-step pages. | manual |
| 6 | Activation events tracked | grep -rEn "trackActivation|activation.*event" --include="*.ts" --include="*.js" . |
Match for at least: signed_up, completed_profile, used_core_feature. | yes |
| 7 | Time-to-activation measured | grep -rEn "activatedAt|activated_at" --include="*.ts" --include="*.js" --include="*.py" . |
Column on user table; populated by checkActivationComplete. |
yes |
| 8 | Re-engagement scheduled for incomplete onboarding | grep -rEn "scheduleTrialReminders|onboarding.*reminder|incomplete.*nudge" --include="*.ts" --include="*.js" --include="*.py" . |
Match: scheduled job that emails users who started but did not finish. | manual |
If any check fails, do not declare done. Fix and re-run.
Anti-Patterns (Excuse / Rebuttal)
Excuse: "Every step is important, so every step is required."
Rebuttal: Every step you mark required is a step where users drop off if it does not apply to them. A user who does not use a calendar will quit the wizard on "connect calendar required". Mark only the steps without which the product genuinely cannot function as required; everything else is optional with sensible defaults. The activation rate gain from making three steps optional is usually larger than the engagement gain from forcing them.
// WRONG: No way to skip
const steps = [
{ id: 'profile', required: true },
{ id: 'connect_calendar', required: true }, // Not everyone uses calendars!
];
// RIGHT: Core steps required, extras optional
const steps = [
{ id: 'profile', required: true },
{ id: 'connect_calendar', required: false },
];
Excuse: "Blocking the app until onboarding is done makes sure they finish it."
Rebuttal: It also makes sure they leave and never come back. Users who hit a wall on first visit close the tab. Allow exploration with the wizard available, gentle nudges for incomplete steps, and explicit "skip for now" with re-engagement later. Forced onboarding is a churn machine.
// WRONG: Can't use app until all steps done
if (!onboarding.isComplete) return <OnboardingWizard />;
// RIGHT: Allow exploration with gentle nudges
if (!onboarding.isComplete && !onboarding.isSkipped) {
return <OnboardingWithExitOption />;
}