Back to Blog

How to Build a SaaS Onboarding Flow That Actually Converts New Users

Published: 2026-06-23
How to Build a SaaS Onboarding Flow That Actually Converts New Users

Most SaaS onboarding flows are designed by a product manager and implemented by a developer who has never tested what happens when a user closes the browser on step three and comes back the next day. Spoiler: they start from step one, and they do not come back a third time.

We have built onboarding flows for enough SaaS products to know the patterns that survive real users. Not the marketing advice about "time to first value" — that is table stakes. The technical implementation: multi-step forms that persist state, resume where the user left off, conditionally branch based on answers, and track every drop-off point.

If you are building an onboarding flow right now and your approach is "a single page with a bunch of conditional useState calls" — this post is for you.

SaaS onboarding flow implementation Next.js — welcome screen on tablet with keyboard and coffee

URL-Based Step Tracking

The first decision is how to track which step the user is on. The answer is URL search params, not component state.

TypeScript
1// app/onboarding/page.tsx
2import { redirect } from "next/navigation"
3
4export default async function OnboardingPage({
5  searchParams,
6}: {
7  searchParams: Promise<{ step?: string }>
8}) {
9  const { step } = await searchParams
10  const currentStep = step ? parseInt(step) : 1
11
12  if (currentStep < 1 || currentStep > 4) {
13    redirect("/onboarding?step=1")
14  }
15
16  return <OnboardingForm step={currentStep} />
17}

URL-based step tracking means the user can bookmark step three, send the link to a teammate, or close the browser and come back without losing their place. The step is in the URL, which is the most durable form of state in a web application.

TypeScript
1"use client"
2
3import { useRouter, useSearchParams } from "next/navigation"
4
5export function OnboardingForm({ step }: { step: number }) {
6  const router = useRouter()
7  const searchParams = useSearchParams()
8
9  const goToStep = (s: number) => {
10    const params = new URLSearchParams(searchParams.toString())
11    params.set("step", s.toString())
12    router.push(`/onboarding?${params.toString()}`)
13  }
14
15  // render current step
16}

The router push updates the URL without a full page reload. The page component reads the step from search params and renders the correct step component. No state management library is needed for step tracking — it is just the URL.

Multi-Step Form Validation with react-hook-form and Zod

Each step submits independently. Validating the entire form only on final submission means the user fills out ten fields, hits an error on one, and has to scroll through everything to figure out what broke.

TypeScript
1import { useForm } from "react-hook-form"
2import { zodResolver } from "@hookform/resolvers/zod"
3import { z } from "zod"
4
5const step1Schema = z.object({
6  fullName: z.string().min(2, "Name must be at least 2 characters"),
7  companyName: z.string().min(1, "Company name is required"),
8  email: z.string().email("Enter a valid email"),
9})
10
11const step2Schema = z.object({
12  teamSize: z.enum(["1", "2-5", "6-20", "21+"]),
13  useCase: z.string().min(1, "Tell us what you are building"),
14})
15
16const step3Schema = z.object({
17  workspaceName: z.string().min(1, "Workspace name is required"),
18})
19
20export const schemas = [step1Schema, step2Schema, step3Schema]

Each step gets its own Zod schema. The form validates only the current step's fields when the user clicks "Next," and displays errors inline. This keeps validation fast and focused.

TypeScript
1type FormData = z.infer<typeof step1Schema> &
2  z.infer<typeof step2Schema> &
3  z.infer<typeof step3Schema>
4
5export function useOnboardingForm(step: number) {
6  return useForm<FormData>({
7    resolver: zodResolver(schemas[step - 1]),
8    mode: "onBlur",
9  })
10}

The resolver switches to the correct schema based on the current step. onBlur mode validates fields when the user tabs out, which means errors appear early without being intrusive.

Multi-step onboarding progress with checklist showing completed tasks

State Persistence Across Sessions

This is the feature that separates onboarding flows that work from ones that lose users. When a user closes the browser on step two and returns tomorrow, they should see step two with their data still filled in.

TypeScript
1import { useEffect } from "react"
2import { useForm } from "react-hook-form"
3
4const STORAGE_KEY = "onboarding-draft"
5
6export function useFormPersistence(form: ReturnType<typeof useForm>) {
7  // Restore saved data on mount
8  useEffect(() => {
9    const saved = localStorage.getItem(STORAGE_KEY)
10    if (saved) {
11      try {
12        const parsed = JSON.parse(saved)
13        Object.entries(parsed).forEach(([key, value]) => {
14          form.setValue(key as any, value as any)
15        })
16      } catch {}
17    }
18  }, [])
19
20  // Save data on every change
21  useEffect(() => {
22    const subscription = form.watch((values) => {
23      localStorage.setItem(STORAGE_KEY, JSON.stringify(values))
24    })
25    return () => subscription.unsubscribe()
26  }, [form])
27}

The hook saves form data to localStorage on every keystroke and restores it when the component mounts. This is transparent to the user — they see the form with their data already filled in, no explanation needed.

Clear onboarding-draft from localStorage only when the onboarding flow completes successfully. If the user never finished onboarding, the draft stays indefinitely and they can pick up where they left off whenever they return.

Progress Indicator Component

The progress indicator is not a decoration — it is the single strongest completion signal in a multi-step flow.

TypeScript
1interface ProgressIndicatorProps {
2  steps: string[]
3  currentStep: number
4}
5
6export function ProgressIndicator({ steps, currentStep }: ProgressIndicatorProps) {
7  const progress = ((currentStep - 1) / (steps.length - 1)) * 100
8
9  return (
10    <div className="mb-8">
11      <div className="flex justify-between mb-2">
12        {steps.map((label, i) => (
13          <span
14            key={label}
15            className={`text-sm ${
16              i + 1 <= currentStep ? "font-semibold" : "text-muted"
17            }`}
18          >
19            {label}
20          </span>
21        ))}
22      </div>
23      <div className="h-2 bg-muted rounded-full overflow-hidden">
24        <div
25          className="h-full bg-primary transition-all duration-300"
26          style={{ width: `${progress}%` }}
27        />
28      </div>
29    </div>
30  )
31}

Keep the step labels short — three to four words max. "Profile," "Workspace," "Team," "Done." Users scan the progress bar to estimate how much is left, not to read paragraphs about what each step contains.

Place the progress indicator at the top of the form, outside the scrollable area, so it stays visible when the user scrolls down a long step.

Conditional Steps Based on User Answers

Not every user should see every step. A solo developer does not need the "invite your team" step. An enterprise user might need a compliance disclosure step that a freelancer never sees.

Build a step configuration array that includes a condition function:

TypeScript
1interface OnboardingStep {
2  id: string
3  component: React.ComponentType<any>
4  condition?: (data: Record<string, any>) => boolean
5}
6
7const steps: OnboardingStep[] = [
8  { id: "profile", component: ProfileStep },
9  { id: "workspace", component: WorkspaceStep },
10  {
11    id: "team",
12    component: InviteTeamStep,
13    condition: (data) => data.teamSize !== "1",
14  },
15  { id: "done", component: DoneStep },
16]
17
18export function getFilteredSteps(data: Record<string, any>): OnboardingStep[] {
19  return steps.filter((s) => !s.condition || s.condition(data))
20}
21
22export function getTotalSteps(data: Record<string, any>): number {
23  return getFilteredSteps(data).length
24}

When the user selects their team size on step two, the progress bar recalculates. If they selected "Just me," the team step is skipped and the total step count drops from four to three. The user never sees a "skipped" step or a progress bar that jumps backward.

The progress bar must always reflect the correct remaining steps for the current user, not the maximum possible steps.

User onboarding analytics tracking with charts and data visualization for step completion

Server Action for Final Submission

When the user completes the final step, submit everything in one Server Action:

TypeScript
1"use server"
2
3import { prisma } from "@/lib/prisma"
4import { z } from "zod"
5
6const onboardingSchema = z.object({
7  fullName: z.string().min(2),
8  companyName: z.string().min(1),
9  email: z.string().email(),
10  teamSize: z.enum(["1", "2-5", "6-20", "21+"]),
11  useCase: z.string().min(1),
12  workspaceName: z.string().min(1),
13})
14
15export async function completeOnboarding(formData: FormData) {
16  const raw = Object.fromEntries(formData)
17  const parsed = onboardingSchema.safeParse(raw)
18
19  if (!parsed.success) {
20    return { error: "Invalid data", issues: parsed.error.issues }
21  }
22
23  const user = await prisma.user.update({
24    where: { email: parsed.data.email },
25    data: {
26      name: parsed.data.fullName,
27      company: parsed.data.companyName,
28      onboardingCompleted: true,
29      onboardingData: parsed.data,
30    },
31  })
32
33  // Trigger post-onboarding actions
34  await createWorkspace(parsed.data.workspaceName, user.id)
35  await sendWelcomeEmail(user.email)
36
37  return { success: true }
38}

The Server Action validates the complete data set one final time, updates the user record, creates the workspace, triggers the welcome email, and returns a success response. The client clears localStorage and redirects to the dashboard.

Do not submit data incrementally for each step. Submitting on every "Next" click means partial data ends up in your database when a user abandons the flow, and you have to write cleanup jobs to handle incomplete records. Keep everything in localStorage until the user completes the whole flow.

Triggering Post-Onboarding Emails

After the Server Action runs, trigger the email sequence. Use a dedicated email service rather than sending inline in the Server Action:

TypeScript
1import { resend } from "@/lib/resend"
2
3export async function sendWelcomeEmail(email: string) {
4  await resend.emails.send({
5    from: "onboarding@codifysaas.com",
6    to: email,
7    subject: "Your workspace is ready",
8    html: `<p>Your workspace is set up. Here is what to do next...</p>`,
9  })
10}
11
12export async function scheduleOnboardingEmails(email: string) {
13  // Day 1: welcome
14  await sendWelcomeEmail(email)
15  // Day 3: advanced feature highlight (queued)
16  await queueEmail(email, "day-3", 3)
17  // Day 7: check-in (queued)
18  await queueEmail(email, "day-7", 7)
19}

Queue the multi-day email sequence rather than sending everything at once. A new user who receives four emails on day one is a new user who marks your domain as spam.

Use the onboarding completion data to personalise the emails — reference the workspace name, the team size, and the use case they selected. A welcome email that says "We set up your '{workspace}' workspace" converts better than one that says "Welcome to the platform."

Analytics: Tracking Drop-Off Points

If you do not know where users abandon onboarding, you cannot fix it. Track a simple conversion funnel:

TypeScript
1export function trackOnboardingEvent(
2  event: "step_view" | "step_complete" | "onboarding_started" | "onboarding_completed",
3  properties?: Record<string, any>
4) {
5  if (typeof window === "undefined") return
6
7  analytics.track(event, {
8    ...properties,
9    timestamp: new Date().toISOString(),
10  })
11}

Fire step_view when the step component mounts. Fire step_complete when the user passes validation and clicks "Next." Fire onboarding_started on first step mount. Fire onboarding_completed after the Server Action returns success.

The ratio between step_view and step_complete for each step tells you exactly where the flow loses users. If step three (team invite) has 400 views but only 200 completions, that step has a problem — the form is too long, the validation is confusing, or the conditional logic is hiding the continue button.

Add step-specific metadata to every event. Track the time spent on each step, the number of validation errors encountered, and whether the user went back to a previous step. A step that users visit twice before completing is a step with a confusing UI.

Skip and Resume Flow

Some users do not want to complete onboarding immediately. Give them an obvious way out:

TypeScript
1export function useSkipOnboarding() {
2  const router = useRouter()
3
4  const skip = () => {
5    localStorage.setItem("onboarding-skipped", "true")
6    router.push("/dashboard")
7  }
8
9  return { skip }
10}

Store the skip state in localStorage and show a persistent banner on the dashboard that invites the user to complete onboarding. The banner should appear on every dashboard visit until onboarding is completed, with a single "Complete setup" button that returns to the onboarding flow at step one.

If the user skips onboarding, do not nag them on every page. One banner, dismissible, reappearing once per session. Users who do not complete onboarding after a week are unlikely to complete it ever — that is a product-market fit signal, not an onboarding problem.


A well-implemented onboarding flow saves its step in the URL, validates per step, persists draft data to localStorage, conditionally skips irrelevant steps, submits everything in one Server Action, triggers a sequenced email campaign, and tracks every step completion as a funnel event.

For more on the form validation patterns used here, see our guide on SaaS role-based permission systems which covers Zod schema design patterns. And for the dashboard users land on after onboarding, our SaaS dashboard architecture guide covers the layout and data fetching patterns.

For client-side form persistence, react-hook-form handles the heavy lifting. For the Server Action pattern, the Next.js Server Actions docs cover the API.

The implementation is not complex — a Zustand store, a URL param, a localStorage hook, a Zod schema per step, one Server Action, and four analytics events. What is rare is doing all of them in the same project, because every team treats onboarding as a one-week feature and moves on. Build the persistence, the branching, and the tracking from day one, and the onboarding flow will still be working a year later — which is more than most features in a codebase can claim.

Frequently Asked Questions

A SaaS onboarding flow is a structured multi-step process that guides new users from signup to their first moment of value. It typically includes account setup, preferences, workspace configuration, and a product tour. The technical implementation handles step navigation, state persistence, conditional branching, and completion tracking.

Use URL search params for the current step number, localStorage for partial form data across sessions, and your database for completed steps. This three-layer approach means users can refresh, close the tab, or return days later and resume exactly where they left off.

Server Actions are the better choice for Next.js onboarding flows because they run in the same request lifecycle as your page components, reducing complexity. Use Server Actions for final submission. Use client-side validation with react-hook-form and Zod for per-step validation before the Server Action fires.

Track a conversion funnel with four events: step_view, step_complete, onboarding_started, and onboarding_completed. Send these to your analytics provider with user properties and step-specific metadata. The step_view vs step_complete ratio tells you exactly where users abandon the flow.

Use react-hook-form with Zod for per-step validation, a Zustand store for transient UI state (current step, animation direction), and localStorage for persistence across sessions. Server state is submitted only on final completion via a Server Action.

Portrait of Umar Farooq

About Umar Farooq

Umar Farooq is the founder and lead engineer of Codify SaaS. He builds B2B SaaS products and web applications on modern TypeScript stacks and enterprise Java, and writes code-first guides drawn from real production work — the schema decisions, the migrations that almost went wrong, and the performance fixes that actually moved the numbers. When he recommends an approach, he shows the code and explains the trade-offs.

Read full bio