Your onboarding flow is the first real product experience every new user has. Research consistently shows that users who reach their “aha moment” — the first time your product delivers clear value — within their first session are dramatically more likely to retain and convert. Poor onboarding means paying for acquisition you can’t recoup.
The mistake most teams make is treating onboarding as an afterthought. They build the core product, bolt on a few modals, and ship it. Then they wonder why 60–70% of trial signups never come back. This guide walks you through building a proper multi-step onboarding flow in Next.js: which screens to include, how to structure the components, and the UX principles that separate flows users complete from flows they abandon.
What a complete SaaS onboarding flow looks like
A well-structured flow typically has five to eight steps:
- Welcome — confirm they’re in the right place, set expectations
- Feature highlights — show the three or four core things your product does
- Profile setup — collect essential personalisation data
- Choose a goal — understand what they’re trying to achieve
- Workspace setup — create their first project or team
- Invite team — get colleagues in early (critical for B2B activation)
- Connect integrations — hook up the tools they already use
- Setup complete — celebrate, confirm what’s next
Not every product needs all eight. A solo tool can skip team invite. A single-tenant app might skip workspace setup. The principle is: collect only what you need to deliver value, in the right order.
Step 1: Tracking progress
Before building any screen, decide how you’ll track state. The simplest approach uses React state for the current step index and total steps count:
const [step, setStep] = useState(1)
const totalSteps = 6
// percentage for the progress bar
const pct = totalSteps > 0 ? Math.min(100, Math.round((step / totalSteps) * 100)) : 0A ProgressStripcomponent renders a thin bar at the top of every screen — a visual anchor that tells the user exactly how far they’ve come and how little is left. Always clamp to 100 and guard against zero totalSteps to avoid NaN in the width style.
function ProgressStrip({ step, totalSteps, stepLabel }: ProgressStripProps) {
const pct = totalSteps > 0 ? Math.min(100, Math.round((step / totalSteps) * 100)) : 0
return (
<div className="w-full mb-6">
<div className="flex justify-between text-xs text-gray-400 mb-1">
<span>{stepLabel}</span>
<span>{step} of {totalSteps}</span>
</div>
<div className="h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div
className="h-full bg-indigo-500 rounded-full transition-all duration-500"
style={{ width: `${pct}%` }}
/>
</div>
</div>
)
}Step 2: Welcome screen
The welcome screen does one job: confirm to the user they’re in the right place and make them feel good about signing up. Keep copy tight. Show your product logo, a headline, and a single CTA.
interface WelcomeProps {
userName?: string
productName: string
tagline: string
ctaLabel?: string
onStart: () => void | Promise<void>
}Don’t ask for anything on this screen. It’s a handshake — “you made it, here’s what happens next.” Any data collection here will drop your completion rate.
Step 3: Feature highlights
Use this screen to frame the three or four most important things your product does. This is not a feature list — it’s a curated “here’s how this changes your work” pitch. Keep each description to a single sentence. If you’re writing two, your list is too long.
interface Feature {
icon: string // e.g. Material Symbols name
title: string
description: string
}
interface FeatureHighlightsProps {
step: number
totalSteps: number
stepLabel: string
headline: string
features: Feature[]
onNext: () => void | Promise<void>
}Step 4: Profile setup
Collect the minimum personal data needed for personalisation: display name, optionally a job title, optionally an avatar. Every additional field reduces completion rates — only ask for data you immediately use. Validate only what is strictly required.
interface ProfileData {
firstName: string // required
lastName?: string
jobTitle?: string
avatar?: File
}
interface ProfileSetupProps {
step: number
totalSteps: number
stepLabel: string
onSave: (data: ProfileData) => Promise<void>
}Set aria-invalid={false} on optional fields rather than tying invalid state to form submission — it avoids incorrectly marking blank optional inputs as erroneous on first render.
Step 5: Goal selection
Understanding why someone signed up tells you how to activate them. Present three to six goals as selectable cards. The selected goal feeds into which features you surface first once onboarding completes.
interface Goal {
id: string
icon: string
label: string
description: string
}
interface ChooseGoalProps {
step: number
totalSteps: number
stepLabel: string
goals: Goal[]
onSelect: (goalId: string) => Promise<void>
}Pass a loading state down to each goal card so they become disabled while theonSelect promise is resolving — this prevents double-submission. Reset form state to "idle" after the promise resolves successfully before advancing.
Step 6: Invite team
For B2B SaaS, getting a second team member in during onboarding is one of the strongest retention signals you can measure. The invite step collects email addresses and dispatches invites. Always include a “Skip for now” path — forcing an invite blocks solo evaluators and costs you completions.
interface Invite {
email: string
role?: string
}
interface InviteTeamProps {
step: number
totalSteps: number
stepLabel: string
roleOptions?: string[]
maxInvites?: number
onSend: (invites: Invite[]) => Promise<void>
onSkip: () => void
}Step 7: Connect integrations
Show relevant integrations as cards with individual Connect buttons. Each integration needs its own loading and success state — connecting Slack shouldn’t grey out the whole screen while you’re waiting for an OAuth callback.
type FormState = "idle" | "loading" | "success" | "error"
// Track per-integration state
const [states, setStates] = useState<Record<string, FormState>>({})
async function handleConnect(id: string) {
setStates(s => ({ ...s, [id]: "loading" }))
try {
await onConnect(id)
setStates(s => ({ ...s, [id]: "success" }))
} catch {
setStates(s => ({ ...s, [id]: "error" }))
}
}Step 8: Setup complete
Close the loop with a celebration screen. Mirror back a checklist of what the user configured (“Profile created”, “Goal set”, “3 invites sent”), optionally fire confetti, and give them a clear CTA to their first meaningful action in the product.
interface SetupCompleteProps {
userName?: string
completedSteps: string[]
ctaLabel: string
ctaHref?: string
onComplete: () => void | Promise<void>
}UX principles that move activation
One decision per screen. If a step asks users to set up a profile and pick a goal, completion rates drop. Each screen should have exactly one primary question or action.
Always provide a skip path.Any step that doesn’t directly improve the product’s ability to serve the user should have a skip option. Users who skip often return once they’ve seen value.
Show optimistic progress.Advance the progress bar the moment the user clicks Next, before async operations resolve. Perceived wait time drops significantly when users can see they’ve already moved forward.
Preserve state on back navigation. If a user goes back from step four to step three, their data should still be there. Losing form data on back navigation is one of the top frustration points in onboarding.
Keep the right col sticky. On wider screens, show the progress strip or a summary panel in a sticky sidebar while the main form scrolls. This gives users a constant sense of where they are.
Decidr
Skip the build — use the Onboarding Pages Pack
Every component described in this guide — Welcome, Feature Highlights, Profile Setup, Choose Goal, Workspace Setup, Invite Team, Connect App, and Setup Complete — is included in the Decidr Onboarding Pages Pack. Eight fully typed React components, three layout variants each, zero UI library dependencies. Copy one file into your project and start customising.
View the Onboarding Pages Packarrow_forward