How to Build an Onboarding Flow That Does Not Lose Users
Most onboarding flows fail at the same point: the gap between sign-up and first value. A user creates an account, lands on an empty dashboard, and leaves. They signed up with intent. Your onboarding failed to convert that intent into action.
We have designed onboarding flows for several products at Threshline, and the one that taught us the most was MindHyv. MindHyv is an all-in-one business platform — social, booking, invoicing, and selling all in one place. That is a lot of surface area for a new user. If we showed everything at once, people would bounce. If we hid too much, people would not discover the features that mattered to them. Getting the balance right was one of the hardest UX challenges we have tackled.
Here is what we learned about onboarding that keeps users around.
Define Your Activation Metric First
Before you design a single screen, define what “activated” means for your product. An activated user is one who has experienced enough value that they are likely to retain.
This is not a vanity metric like “completed onboarding” or “visited the dashboard.” It is the specific action that correlates with long-term retention. Some examples:
- For a feedback tool like Trackelio: linked at least one feedback source and tagged 5 pieces of feedback
- For a freelancer workspace like LancerSpace: created a client and sent their first proposal
- For MindHyv: set up a profile and published their first post or service listing
Your entire onboarding flow exists to get users to this moment as fast as possible. Every screen, every step, every piece of copy should push toward activation. If a step does not contribute to activation, remove it.
To track activation, you need to instrument the key events:
// Simple activation tracking
type ActivationEvent =
| "profile_completed"
| "first_source_connected"
| "first_feedback_tagged"
| "first_client_created"
| "first_proposal_sent"
| "first_post_published";
async function trackActivation(
workspaceId: string,
event: ActivationEvent
) {
await supabase.from("activation_events").upsert(
{
workspace_id: workspaceId,
event,
completed_at: new Date().toISOString(),
},
{ onConflict: "workspace_id,event" }
);
// Check if user is now fully activated
const { data: events } = await supabase
.from("activation_events")
.select("event")
.eq("workspace_id", workspaceId);
const requiredEvents: ActivationEvent[] = [
"profile_completed",
"first_source_connected",
"first_feedback_tagged",
];
const completedAll = requiredEvents.every((req) =>
events?.some((e) => e.event === req)
);
if (completedAll) {
await supabase
.from("workspaces")
.update({ activated_at: new Date().toISOString() })
.eq("id", workspaceId);
}
}
Progressive Profiling Over Long Forms
The worst onboarding pattern is the wall of form fields. Name, company, role, team size, industry, use case, how did you hear about us — all on one page, before the user has seen a single pixel of your product.
Progressive profiling flips this. Collect the minimum at sign-up (email and password). Then gather additional information in context, when the user can see why you are asking.
For MindHyv, the sign-up form asks for just three things: email, password, and display name. That is enough to create an account and land on a personalized onboarding experience. We ask for business type, services offered, and location later — when the user is setting up their profile and can see exactly how that information will appear on their public page.
The technical pattern is a multi-step onboarding that records progress:
create table onboarding_progress (
workspace_id uuid primary key references workspaces(id),
current_step int not null default 0,
steps_completed jsonb not null default '[]',
skipped_steps jsonb not null default '[]',
started_at timestamptz default now(),
completed_at timestamptz
);
type OnboardingStep = {
id: string;
title: string;
description: string;
required: boolean;
component: string;
};
const onboardingSteps: OnboardingStep[] = [
{
id: "workspace_setup",
title: "Set up your workspace",
description: "Give your workspace a name and invite your team.",
required: true,
component: "WorkspaceSetupStep",
},
{
id: "connect_source",
title: "Connect a feedback source",
description: "Import feedback from Slack, Intercom, or email.",
required: true,
component: "ConnectSourceStep",
},
{
id: "invite_team",
title: "Invite your team",
description: "Add team members who should see customer feedback.",
required: false,
component: "InviteTeamStep",
},
{
id: "customize",
title: "Customize your workflow",
description: "Set up tags, statuses, and priorities that match your process.",
required: false,
component: "CustomizeStep",
},
];
Notice that some steps are required and others are not. Required steps are the minimum path to activation. Optional steps add value but should not block progress. Always let users skip optional steps — they can come back to them later.

The Checklist Pattern
Checklists work because they tap into the psychological need for completion. Showing a user “3 of 5 steps completed” creates a mild but effective pull toward finishing.
Here is a checklist component pattern we have used across multiple products:
<!-- OnboardingChecklist.svelte -->
<script lang="ts">
type Step = {
id: string;
title: string;
description: string;
completed: boolean;
required: boolean;
href: string;
};
type Props = {
steps: Step[];
onDismiss: () => void;
};
let { steps, onDismiss }: Props = $props();
const completedCount = $derived(steps.filter((s) => s.completed).length);
const totalCount = $derived(steps.length);
const progress = $derived(Math.round((completedCount / totalCount) * 100));
const allRequiredDone = $derived(
steps.filter((s) => s.required).every((s) => s.completed)
);
</script>
<div class="rounded-lg border border-gray-200 bg-white p-6">
<div class="mb-4 flex items-center justify-between">
<div>
<h3 class="text-lg font-semibold">Getting started</h3>
<p class="text-sm text-gray-500">
{completedCount} of {totalCount} completed
</p>
</div>
{#if allRequiredDone}
<button
onclick={onDismiss}
class="text-sm text-gray-400 hover:text-gray-600"
>
Dismiss
</button>
{/if}
</div>
<!-- Progress bar -->
<div class="mb-6 h-2 overflow-hidden rounded-full bg-gray-100">
<div
class="h-full rounded-full bg-blue-600 transition-all duration-500"
style="width: {progress}%"
></div>
</div>
<!-- Steps -->
<div class="space-y-3">
{#each steps as step}
<a
href={step.href}
class="flex items-start gap-3 rounded-md p-3 transition-colors
{step.completed
? 'bg-gray-50'
: 'bg-blue-50 hover:bg-blue-100'}"
>
<div class="mt-0.5">
{#if step.completed}
<svg class="h-5 w-5 text-green-500" fill="currentColor"
viewBox="0 0 20 20">
<path fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0
00-1.414-1.414L9 10.586 7.707 9.293a1 1 0
00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clip-rule="evenodd" />
</svg>
{:else}
<div class="h-5 w-5 rounded-full border-2 border-gray-300"></div>
{/if}
</div>
<div>
<p class="text-sm font-medium {step.completed
? 'text-gray-400 line-through' : 'text-gray-900'}">
{step.title}
{#if !step.required}
<span class="text-xs text-gray-400 no-underline">(optional)</span>
{/if}
</p>
{#if !step.completed}
<p class="text-xs text-gray-500">{step.description}</p>
{/if}
</div>
</a>
{/each}
</div>
</div>
Where to place the checklist matters. For new users, it should be the most prominent element on the dashboard — not buried in a sidebar or behind a modal. As users complete steps, the checklist naturally shrinks. Once all required steps are done, let the user dismiss it.
We usually persist the dismissal:
async function dismissChecklist(workspaceId: string) {
await supabase
.from("onboarding_progress")
.update({ completed_at: new Date().toISOString() })
.eq("workspace_id", workspaceId);
}
Quick Time-to-Value
The most important principle in onboarding is reducing time-to-value — the time between sign-up and the moment the user thinks “this is useful.”
There are several strategies for this:
Pre-populated data. Instead of starting with a blank slate, seed the workspace with sample data. A project management tool can include a “Sample Project” with a few tasks. A CRM can include a dummy client record. This lets the user experience the product’s value immediately, before they have entered any of their own data.
async function seedSampleData(workspaceId: string) {
// Create a sample project
const { data: project } = await supabase
.from("projects")
.insert({
workspace_id: workspaceId,
name: "Sample Project",
description:
"This is a sample project to help you explore. Feel free to edit or delete it.",
status: "active",
})
.select()
.single();
if (!project) return;
// Add sample tasks
const sampleTasks = [
{
project_id: project.id,
workspace_id: workspaceId,
title: "Explore your dashboard",
description: "Click around and see what's available.",
status: "done",
},
{
project_id: project.id,
workspace_id: workspaceId,
title: "Create your first real task",
description: "Try adding a task for something you're actually working on.",
status: "todo",
},
{
project_id: project.id,
workspace_id: workspaceId,
title: "Invite a team member",
description: "Collaboration works better with your team.",
status: "todo",
},
];
await supabase.from("tasks").insert(sampleTasks);
}
Template selection. Let the user choose a template that matches their use case. A freelancer workspace like LancerSpace might offer templates for different freelancer types — designer, developer, writer — each pre-configured with relevant project structures and proposal templates.
Import from existing tools. If the user is switching from a competitor or a spreadsheet, an import flow gets them to value faster than manual data entry. Even a simple CSV import can make a massive difference in activation rates.
Empty State Design
Empty states are the most undervalued screens in product design. They appear at the exact moment when the user is deciding whether your product is worth their time. An empty list with the text “No items found” is a dead end.
Good empty states do three things:
- Explain what belongs here. “This is where your client proposals will appear.”
- Show what it will look like. A faded preview of what the populated state looks like gives the user a mental model of the goal.
- Provide a clear action. One button. Not three options — one. “Create your first proposal.”
Here is an empty state pattern for a project list:
<!-- EmptyProjectsState.svelte -->
<script lang="ts">
type Props = {
onCreate: () => void;
};
let { onCreate }: Props = $props();
</script>
<div class="mx-auto max-w-md py-12 text-center">
<!-- Illustration or preview -->
<div class="mx-auto mb-6 w-64 rounded-lg border-2 border-dashed
border-gray-200 p-4">
<div class="space-y-2 opacity-40">
<div class="flex items-center gap-2">
<div class="h-3 w-3 rounded-full bg-green-400"></div>
<div class="h-3 flex-1 rounded bg-gray-200"></div>
</div>
<div class="flex items-center gap-2">
<div class="h-3 w-3 rounded-full bg-yellow-400"></div>
<div class="h-3 flex-1 rounded bg-gray-200"></div>
</div>
<div class="flex items-center gap-2">
<div class="h-3 w-3 rounded-full bg-blue-400"></div>
<div class="h-3 flex-1 rounded bg-gray-200"></div>
</div>
</div>
</div>
<h3 class="text-lg font-semibold text-gray-900">
No projects yet
</h3>
<p class="mt-2 text-sm text-gray-500">
Projects help you organize work and track progress.
Create your first one to get started.
</p>
<button
onclick={onCreate}
class="mt-6 rounded-md bg-blue-600 px-4 py-2 text-sm font-medium
text-white hover:bg-blue-700"
>
Create your first project
</button>
</div>
We wrote about loading states and skeleton screens in a separate post — the transition from loading to empty state is an often-overlooked moment that deserves specific attention.
Contextual Tooltips Over Product Tours
Full product tours — the kind where a modal walks you through every feature with numbered steps — have terrible completion rates. Users close them immediately. The ones who do complete them forget everything by the time they need it.
Contextual hints work better. Show a tooltip when the user encounters a feature for the first time, not during a forced tour at the start.
type TooltipHint = {
id: string;
target: string; // CSS selector
title: string;
content: string;
trigger: "first_visit" | "first_use" | "hover";
};
const contextualHints: TooltipHint[] = [
{
id: "feedback_tag",
target: "[data-hint='tag-feedback']",
title: "Tag feedback to features",
content:
"Link customer feedback to feature requests to track demand. Click any tag to filter.",
trigger: "first_visit",
},
{
id: "keyboard_shortcuts",
target: "[data-hint='search']",
title: "Quick search",
content: "Press Cmd+K to search across all feedback and features.",
trigger: "first_use",
},
];
// Track which hints have been shown
async function markHintSeen(userId: string, hintId: string) {
await supabase.from("user_hints_seen").upsert({
user_id: userId,
hint_id: hintId,
seen_at: new Date().toISOString(),
});
}
async function getUnseenHints(userId: string): Promise<string[]> {
const { data: seen } = await supabase
.from("user_hints_seen")
.select("hint_id")
.eq("user_id", userId);
const seenIds = new Set(seen?.map((s) => s.hint_id) ?? []);
return contextualHints
.filter((h) => !seenIds.has(h.id))
.map((h) => h.id);
}
The advantage of contextual hints is that they arrive when the user has context. A tooltip about tagging feedback means nothing during a product tour. It means everything when the user is staring at an untagged feedback item and wondering what to do next.

Multi-Step Onboarding Layout
For products that genuinely need a multi-step setup process (workspace configuration, integrations, team invites), the step-by-step layout needs to feel lightweight and fast:
<!-- OnboardingLayout.svelte -->
<script lang="ts">
import { goto } from "$app/navigation";
type Props = {
currentStep: number;
totalSteps: number;
canSkip: boolean;
canProceed: boolean;
onNext: () => Promise<void>;
onSkip?: () => void;
children: any;
};
let {
currentStep,
totalSteps,
canSkip = false,
canProceed,
onNext,
onSkip,
children,
}: Props = $props();
let submitting = $state(false);
async function handleNext() {
submitting = true;
try {
await onNext();
} finally {
submitting = false;
}
}
</script>
<div class="flex min-h-screen flex-col bg-gray-50">
<!-- Minimal header -->
<header class="border-b bg-white px-6 py-4">
<div class="mx-auto flex max-w-2xl items-center justify-between">
<span class="text-lg font-bold">YourApp</span>
<span class="text-sm text-gray-500">
Step {currentStep} of {totalSteps}
</span>
</div>
</header>
<!-- Progress -->
<div class="h-1 bg-gray-200">
<div
class="h-full bg-blue-600 transition-all duration-300"
style="width: {(currentStep / totalSteps) * 100}%"
></div>
</div>
<!-- Content -->
<main class="flex flex-1 items-start justify-center px-6 py-12">
<div class="w-full max-w-lg">
{@render children()}
</div>
</main>
<!-- Footer with navigation -->
<footer class="border-t bg-white px-6 py-4">
<div class="mx-auto flex max-w-2xl items-center justify-between">
<div>
{#if canSkip && onSkip}
<button
onclick={onSkip}
class="text-sm text-gray-500 hover:text-gray-700"
>
Skip this step
</button>
{/if}
</div>
<button
onclick={handleNext}
disabled={!canProceed || submitting}
class="rounded-md bg-blue-600 px-6 py-2 text-sm font-medium
text-white hover:bg-blue-700
disabled:opacity-50 disabled:cursor-not-allowed"
>
{#if submitting}
Saving...
{:else if currentStep === totalSteps}
Get Started
{:else}
Continue
{/if}
</button>
</div>
</footer>
</div>
Key design decisions in this layout:
- No sidebar navigation. Users should not be able to jump around. Each step builds on the previous one.
- Progress indicator at the top. Users need to know where they are and how much is left. Uncertainty about length causes abandonment.
- Skip option for non-required steps. Clearly labeled, but visually de-emphasized so it is not the default path.
- One primary action per step. “Continue” or “Get Started” on the final step. No ambiguity about what to do next.
Measuring Onboarding Success
You cannot improve what you do not measure. Track these metrics for your onboarding flow:
Step completion rate. What percentage of users complete each step? A steep drop at step 3 tells you step 3 is the problem, not step 5.
Time to activation. How long from sign-up to the activation event? If it takes 20 minutes and activation correlates with retention, you need to cut that time.
Activation rate by cohort. What percentage of users who signed up this week reached activation? Track this weekly. It is the single most important metric for onboarding health.
Skip rate per step. If 70% of users skip a step, either the step is not valuable or it is not clear why it matters. Either remove it or rewrite the copy.

-- Query: step completion funnel for this week's signups
select
'signed_up' as step,
count(*) as users
from workspaces
where created_at >= date_trunc('week', current_date)
union all
select
e.event as step,
count(distinct e.workspace_id) as users
from activation_events e
join workspaces w on w.id = e.workspace_id
where w.created_at >= date_trunc('week', current_date)
group by e.event
order by users desc;
Onboarding Never Ends
The mistake many teams make is treating onboarding as a one-time flow that ends when the user dismisses the checklist. In reality, onboarding happens every time a user encounters a new feature, joins a new workspace, or returns after a long absence.
Think of onboarding as a spectrum:
- First-run onboarding. The initial setup flow. Gets the user to activation.
- Feature onboarding. Contextual hints when a user encounters a new feature for the first time.
- Return onboarding. A “welcome back” experience for users who have been inactive. Show what changed since their last visit.
- Role onboarding. When a new team member joins an existing workspace. They need to understand the product without reconfiguring what the admin already set up.
Each of these is a different experience with different goals, but they all follow the same principles: minimize friction, maximize time-to-value, and measure relentlessly.
The products that retain users are the ones that make every first experience — whether it is the first sign-up or the first time using a feature — feel guided and intentional.
If you are building a product and want onboarding that turns sign-ups into active users, reach out at hello@threshline.com.