SaaS onboarding wizard
Onboarding determines the activation rate — the percentage of users who reach the moment of perceiving product value. A wizard is a step-by-step scenario that guides the user to first success.
Designing steps
The goal of onboarding is to get to the "aha moment" in minimum time. Each extra step reduces completion rate.
Step 1: Company profile (name, type, size)
Step 2: Invite first team member
Step 3: Create first project (key action)
Step 4: Connect integration (Slack/GitHub/Jira)
→ Aha moment: first active project with team
Data schema
model OnboardingProgress {
id String @id @default(cuid())
tenantId String @unique
currentStep Int @default(0)
completedAt DateTime?
steps Json // { "profile": true, "invite": false, "project": false, "integration": false }
startedAt DateTime @default(now())
tenant Tenant @relation(fields: [tenantId], references: [id])
}
Wizard component
// components/onboarding/OnboardingWizard.tsx
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
interface Step {
id: string;
title: string;
component: React.ComponentType<StepProps>;
optional?: boolean;
}
const STEPS: Step[] = [
{ id: 'profile', title: 'About your company', component: ProfileStep },
{ id: 'invite', title: 'Invite team', component: InviteStep, optional: true },
{ id: 'project', title: 'First project', component: CreateProjectStep },
{ id: 'integration', title: 'Connect tools', component: IntegrationStep, optional: true },
];
export function OnboardingWizard({
initialStep,
completedSteps,
}: {
initialStep: number;
completedSteps: Record<string, boolean>;
}) {
const [currentStep, setCurrentStep] = useState(initialStep);
const [completed, setCompleted] = useState(completedSteps);
const router = useRouter();
const step = STEPS[currentStep];
const StepComponent = step.component;
const handleNext = async (skipValidation = false) => {
if (!skipValidation) {
// Mark step as completed
await updateStepProgress(step.id);
setCompleted(prev => ({ ...prev, [step.id]: true }));
}
if (currentStep < STEPS.length - 1) {
setCurrentStep(prev => prev + 1);
} else {
// Onboarding complete
await completeOnboarding();
router.push('/dashboard');
}
};
return (
<div className="max-w-2xl mx-auto py-12 px-4">
{/* Progress bar */}
<div className="mb-8">
<div className="flex items-center gap-2">
{STEPS.map((s, idx) => (
<div key={s.id} className="flex items-center gap-2">
<div className={`
w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium
${completed[s.id]
? 'bg-green-500 text-white'
: idx === currentStep
? 'bg-blue-600 text-white'
: 'bg-gray-200 text-gray-500'
}
`}>
{completed[s.id] ? '✓' : idx + 1}
</div>
{idx < STEPS.length - 1 && (
<div className={`flex-1 h-0.5 ${completed[s.id] ? 'bg-green-500' : 'bg-gray-200'}`} />
)}
</div>
))}
</div>
<p className="mt-2 text-sm text-gray-500">
Step {currentStep + 1} of {STEPS.length}: {step.title}
</p>
</div>
{/* Step content */}
<StepComponent
onNext={handleNext}
onSkip={step.optional ? () => handleNext(true) : undefined}
/>
</div>
);
}
Wizard steps
// components/onboarding/steps/ProfileStep.tsx
export function ProfileStep({ onNext }: StepProps) {
const form = useForm<ProfileForm>({
resolver: zodResolver(profileSchema),
});
const onSubmit = async (data: ProfileForm) => {
await updateOrganizationProfile(data);
onNext();
};
return (
<form onSubmit={form.handleSubmit(onSubmit)}>
<h2 className="text-2xl font-bold mb-6">Tell us about your company</h2>
<FormField
label="Company name"
error={form.formState.errors.name?.message}
>
<Input {...form.register('name')} placeholder="Acme Corp" autoFocus />
</FormField>
<FormField label="Team type" error={form.formState.errors.teamType?.message}>
<Select {...form.register('teamType')}>
<option value="startup">Startup</option>
<option value="agency">Agency</option>
<option value="enterprise">Enterprise</option>
<option value="freelancer">Freelancer</option>
</Select>
</FormField>
<FormField label="Team size">
<Select {...form.register('teamSize')}>
<option value="1">Just me</option>
<option value="2-10">2–10 people</option>
<option value="11-50">11–50 people</option>
<option value="50+">50+ people</option>
</Select>
</FormField>
<Button type="submit" className="w-full mt-6" isLoading={form.formState.isSubmitting}>
Continue
</Button>
</form>
);
}
Onboarding tracking
// Track onboarding completion rate by step
export async function trackOnboardingStep(step: string, tenantId: string) {
posthog.capture('onboarding_step_completed', {
distinct_id: tenantId,
step,
timestamp: new Date().toISOString(),
});
}
// In analytics, track funnel:
// onboarding_started → profile_completed → team_invited → project_created
// Find the largest drop-off
Resume onboarding
// Progress is saved — user can return later
// On each login, check onboarding status
export async function checkOnboardingStatus(tenantId: string) {
const progress = await db.onboardingProgress.findUnique({
where: { tenantId }
});
if (!progress?.completedAt) {
// Redirect to next incomplete step
return {
completed: false,
currentStep: progress?.currentStep ?? 0,
};
}
return { completed: true };
}
Developing an onboarding wizard with tracking and progress recovery — 3–5 working days.







