Onboarding is the most important funnel in your SaaS. If users do not reach the "aha moment" within their first session, they will not return. This guide covers a systematic approach to onboarding design — activation milestones, progressive disclosure, guided tours, empty states, and the metrics that separate effective onboarding from ineffective onboarding. The patterns described here power the onboarding flow at tanstackship.com.
The Onboarding Funnel
A typical SaaS signup funnel looks like this:
1,000 website visitors
→ 50 signups (5% conversion)
→ 25 complete setup (50% activation)
→ 10 reach "aha moment" (40% of activated)
→ 5 convert to paid (50% conversion)
→ 3 remain active after 90 days (60% retention)
The biggest drop-off is between signup and activation. Most SaaS products lose 60-80% of signups in the first session. Fixing this drop-off has the highest ROI of any optimization.
The Activation Milestone
Every SaaS has an "aha moment" — the point where users experience the core value. Define yours:
Activation Milestone Examples
| SaaS Type | Aha Moment | Metrics to Track |
|---|---|---|
| Project management | First project created with 3+ tasks | Projects created, tasks added |
| Analytics | First dashboard with live data | Data sources connected, first report viewed |
| CRM | First contact imported and organized | Contacts imported, lists created |
| Design tool | First design export | Assets created, exports completed |
| Developer tool | First successful deployment | Deployments, API calls |
For a SaaS starter like tanstackship.com, the activation milestone might be: "User configures their first subscription plan and sees the billing preview."
The First Session
Minute 1: Value Preview
Show the "end state" immediately. Do not make users configure before they can see value:
// components/onboarding/ValuePreview.tsx
function ValuePreview() {
return (
<div className="onboarding-step">
<h2>See what your dashboard will look like</h2>
<div className="mockup-container">
<DashboardMockup /> {/* Pre-rendered example */}
</div>
<p className="hint">
Your actual data will appear here once configured.
This is what it will look like.
</p>
<button onClick={() => startSetup()}>
Start Setup (2 minutes)
</button>
</div>
)
}
Minutes 2-5: Progressive Setup
Ask for the minimum information needed to show value:
// server/onboarding.ts
export const completeQuickStart = createServerFn({ method: "POST" }).handler(
async ({ data, context }: {
data: {
companyName: string
productType: string
}
}) => {
// Only ask for essential info in the first session
await context.env.DB.prepare(`
UPDATE users SET
company_name = ?,
product_type = ?,
onboarding_step = 'first_dashboard'
WHERE id = ?
`).bind(data.companyName, data.productType, context.user.id).run()
// Generate initial data based on product type
await generateSampleData(context.user.id, data.productType)
return { redirectTo: "/dashboard" }
}
)
Onboarding Flow Patterns
Pattern 1: Guided Checklist
// components/onboarding/Checklist.tsx
type OnboardingStep = {
id: string
title: string
description: string
isComplete: boolean
link: string
}
function OnboardingChecklist({ steps }: { steps: OnboardingStep[] }) {
const completed = steps.filter((s) => s.isComplete).length
return (
<div className="onboarding-checklist">
<div className="progress-bar">
<div
className="progress-fill"
style={{ width: `${(completed / steps.length) * 100}%` }}
/>
</div>
<p className="progress-text">
{completed} of {steps.length} steps complete
</p>
<ul>
{steps.map((step) => (
<li key={step.id} className={step.isComplete ? "done" : "pending"}>
{step.isComplete ? (
<CheckIcon />
) : (
<Link to={step.link}>
<span>{step.title}</span>
<span className="description">{step.description}</span>
</Link>
)}
</li>
))}
</ul>
</div>
)
}
Pattern 2: Progressive Disclosure
Show advanced features only after the user has completed basics:
// Fetch user's current capability level
const capabilityLevel = await context.env.DB.prepare(`
SELECT onboarding_step, features_unlocked
FROM users WHERE id = ?
`).bind(userId).first()
// Only show advanced features if the basics are done
const features = capabilityLevel.features_unlocked
? allFeatures
: allFeatures.filter((f) => !f.requiresSetup)
Pattern 3: Empty States That Guide
Every empty page is an onboarding opportunity:
// components/EmptyState.tsx
function EmptySubscriptionList() {
return (
<div className="empty-state">
<Icon name="subscriptions" />
<h3>No subscriptions yet</h3>
<p>Your first subscription is a few clicks away.</p>
<Button onClick={() => navigate("/settings/stripe")}>
Connect Stripe → See subscriptions in seconds
</Button>
</div>
)
}
Measuring Onboarding Effectiveness
Key Metrics
| Metric | Definition | Good | Needs Work |
|---|---|---|---|
| Time to Activation | Time from signup to first aha moment | < 5 min | > 15 min |
| Activation Rate | % of signups who reach activation | > 50% | < 30% |
| Setup Completion | % who complete onboarding checklist | > 70% | < 40% |
| Day 1 Retention | % who return within 24 hours | > 60% | < 30% |
| Day 7 Retention | % who return within 7 days | > 40% | < 20% |
Tracking in D1
// server/onboarding/tracking.ts
export const trackOnboardingStep = createServerFn({ method: "POST" }).handler(
async ({ data, context }: {
data: { step: string; duration: number; error?: string }
}) => {
await context.env.DB.prepare(`
INSERT INTO onboarding_events
(id, user_id, step, duration_ms, error, created_at)
VALUES (?, ?, ?, ?, ?, ?)
`).bind(
crypto.randomUUID(),
context.user.id,
data.step,
data.duration,
data.error ?? null,
Date.now()
).run()
}
)
A/B Testing Onboarding Flows
// Assign user to a variant
export const getOnboardingVariant = createServerFn({ method: "GET" }).handler(
async ({}, { context }) => {
const variants = ["guided_checklist", "progressive_disclosure", "video_tour"]
const index = Math.abs(hashString(context.user.id)) % variants.length
const variant = variants[index]
// Track assignment
await context.env.DB.prepare(`
INSERT INTO ab_tests
(id, user_id, test_name, variant, created_at)
VALUES (?, ?, 'onboarding_flow', ?, ?)
`).bind(crypto.randomUUID(), context.user.id, variant, Date.now()).run()
return { variant }
}
)
The Onboarding-to-Paid Transition
The moment between activation and first payment is delicate. The user has seen value but has not yet committed:
// Trigger when user completes activation milestone
export const checkActivation = createServerFn({ method: "GET" }).handler(
async ({}, { context }) => {
const user = await context.env.DB.prepare(`
SELECT onboarding_step, subscription_status
FROM users WHERE id = ?
`).bind(context.user.id).first()
if (user.onboarding_step === 'complete' && !user.subscription_status) {
// User is activated but not subscribed → show upgrade prompt
return {
showUpgrade: true,
timing: 'post_activation',
}
}
}
)
Onboarding Anti-Patterns
| Anti-Pattern | Why It Fails | Better Approach |
|---|---|---|
| Ask for everything upfront | Overwhelms users | Progressive disclosure |
| No guided tour | Users get lost | Contextual tooltips |
| Perfect data required | Users cannot start | Sample data pre-populated |
| All features shown at once | Cognitive overload | Feature gating by progress |
| No progress indication | Users do not know how far they are | Visual progress bar |
| Email-only onboarding | Low engagement | In-app guidance |
| No value preview | Users must build before they see | Pre-rendered examples |
Advanced: Using UTM Data to Personalize Onboarding
If the user arrived from a specific campaign, customize their onboarding:
export const getPersonalizedOnboarding = createServerFn({ method: "GET" }).handler(
async ({}, { context }) => {
const utmData = await context.env.DB.prepare(`
SELECT utm_source, utm_campaign, utm_content
FROM utm_clicks
WHERE user_id = ?
ORDER BY created_at DESC
LIMIT 1
`).bind(context.user.id).first()
// Different onboarding for different sources
if (utmData?.utm_campaign === "feature_stripe") {
return { template: "stripe_focused_onboarding" }
}
return { template: "default_onboarding" }
}
)
Onboarding Flow Checklist
- [ ] Define the activation milestone (what is the "aha moment"?)
- [ ] Track every onboarding step with timing data
- [ ] Empty states guide users toward the next action
- [ ] Progress indicator visible throughout onboarding
- [ ] Sample data available for immediate value
- [ ] Advanced features gated behind basic setup
- [ ] Upgrade prompt shown after activation, not before
- [ ] A/B testing infrastructure for onboarding variants
- [ ] Email follow-up for users who do not complete setup
- [ ] Onboarding analytics dashboard to monitor drop-offs
Conclusion
Onboarding is not a tutorial — it is the bridge between a curious signup and an active user. The best onboarding flows are invisible: users experience the value of the product before they realize they are being guided.
The principles are universal:
- Show value immediately (preview, mockup, sample data)
- Ask for the minimum information needed to progress
- Guide through empty states (not modals or overlays)
- Measure every step with timing data
- Personalize based on acquisition source
For a SaaS starter with built-in onboarding infrastructure — progress tracking, A/B testing, UTM-based personalization — see tanstackship.com.
Top comments (0)