Introduction
Forms are the unsung workhorses of the web. Every signup flow, checkout page, onboarding wizard, and support ticket starts with someone typing into a box. Yet forms are also one of the most commonly botched pieces of UI — especially when they grow long enough to need multiple steps.
A poorly built multi-step form frustrates users, tanks conversion rates, and quietly excludes people who rely on assistive technology. A well-built one feels almost invisible: it guides the user, validates as they go, and never makes them guess what went wrong.
In this guide, we're going to build a genuinely production-grade multi-step form using:
- Next.js App Router (the current standard for React server rendering as of 2026)
- Server Actions for mutation logic, instead of legacy API routes
- Shadcn UI components, built on Radix primitives, for accessible interactive elements
- React Hook Form + Zod for type-safe, schema-driven validation
- Tailwind CSS for styling and responsive layout
By the end, you'll have a reusable wizard pattern you can drop into any onboarding flow, checkout process, or multi-part application form — and, just as importantly, you'll understand why each decision was made, not just how to copy-paste it.
This isn't a "hello world" tutorial. We're going to cover the parts that usually get skipped: focus management between steps, screen reader announcements, error recovery, and SEO considerations for form-heavy pages.
Why Multi-Step Forms (And When to Avoid Them)
Before writing a single line of code, it's worth asking: does this form actually need multiple steps?
Multi-step forms work well when:
- The form has 10+ fields and grouping them reduces perceived effort
- Fields fall into natural categories (e.g., "Personal Info," "Shipping," "Payment")
- You want to validate progressively so users get feedback before reaching the end
- You need conditional branching (e.g., different fields based on account type)
They work poorly when:
- The form only has 3-5 fields — splitting it just adds friction
- Users frequently need to jump back and forth between unrelated fields
- The steps aren't logically distinct, and pagination feels arbitrary
If your form doesn't clearly benefit from chunking, a single well-organized page with fieldsets is usually faster to build and easier to use. Multi-step forms are a UX tool, not a default pattern.
Project Setup
Assuming you already have a Next.js App Router project, install the dependencies we'll need:
npx shadcn@latest init
npx shadcn@latest add button input label progress form card separator
npm install react-hook-form zod @hookform/resolvers
Your project structure for this feature will look roughly like this:
app/
onboarding/
page.tsx
actions.ts
components/
onboarding/
form-wizard.tsx
step-account.tsx
step-profile.tsx
step-review.tsx
progress-indicator.tsx
lib/
validation/
onboarding-schema.ts
Keeping validation schemas in lib/validation (rather than inline in components) is a small decision that pays off quickly — both your client components and your Server Action will import from the same source of truth.
Designing the Schema First
A multi-step form is really just one big form with a UI that reveals fields incrementally. The cleanest way to model this is to define one Zod schema per step, then merge them for the final submission.
// lib/validation/onboarding-schema.ts
import { z } from "zod";
export const accountStepSchema = z.object({
email: z.string().email("Enter a valid email address"),
password: z
.string()
.min(8, "Password must be at least 8 characters")
.regex(/[0-9]/, "Password must include a number"),
});
export const profileStepSchema = z.object({
fullName: z.string().min(2, "Full name is required"),
role: z.enum(["developer", "designer", "manager", "other"], {
required_error: "Please select a role",
}),
});
export const reviewStepSchema = z.object({
acceptTerms: z.literal(true, {
errorMap: () => ({ message: "You must accept the terms to continue" }),
}),
});
// Combined schema used for final server-side validation
export const onboardingSchema = accountStepSchema
.merge(profileStepSchema)
.merge(reviewStepSchema);
export type OnboardingValues = z.infer<typeof onboardingSchema>;
export type AccountStepValues = z.infer<typeof accountStepSchema>;
export type ProfileStepValues = z.infer<typeof profileStepSchema>;
export type ReviewStepValues = z.infer<typeof reviewStepSchema>;
This gives us three benefits at once:
- Per-step validation — each step only validates the fields relevant to it.
- A merged schema — used once, server-side, to guarantee the entire payload is valid before it touches your database.
- Inferred TypeScript types — no manual interface duplication, no drift between your validation rules and your types.
Building the Wizard Shell
The wizard component owns the current step index and the accumulated form state. We use React Hook Form's useForm at the top level so that data persists as the user moves between steps, rather than re-mounting fresh forms each time.
// components/onboarding/form-wizard.tsx
"use client";
import { useState, useRef } from "react";
import { useForm, FormProvider } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import {
onboardingSchema,
accountStepSchema,
profileStepSchema,
type OnboardingValues,
} from "@/lib/validation/onboarding-schema";
import { submitOnboarding } from "@/app/onboarding/actions";
import { ProgressIndicator } from "./progress-indicator";
import { StepAccount } from "./step-account";
import { StepProfile } from "./step-profile";
import { StepReview } from "./step-review";
import { Button } from "@/components/ui/button";
const steps = [
{ id: "account", label: "Account", schema: accountStepSchema },
{ id: "profile", label: "Profile", schema: profileStepSchema },
{ id: "review", label: "Review", schema: null },
] as const;
export function FormWizard() {
const [currentStep, setCurrentStep] = useState(0);
const [serverError, setServerError] = useState<string | null>(null);
const [isPending, setIsPending] = useState(false);
const liveRegionRef = useRef<HTMLDivElement>(null);
const methods = useForm<OnboardingValues>({
resolver: zodResolver(onboardingSchema),
mode: "onBlur",
defaultValues: {
email: "",
password: "",
fullName: "",
role: undefined,
acceptTerms: false as unknown as true,
},
});
const isLastStep = currentStep === steps.length - 1;
async function goNext() {
const stepSchema = steps[currentStep].schema;
if (stepSchema) {
const fields = Object.keys(stepSchema.shape) as (keyof OnboardingValues)[];
const valid = await methods.trigger(fields);
if (!valid) {
announce(`Please fix the errors in the ${steps[currentStep].label} step`);
return;
}
}
setCurrentStep((s) => Math.min(s + 1, steps.length - 1));
announce(`Step ${currentStep + 2} of ${steps.length}: ${steps[currentStep + 1]?.label}`);
}
function goBack() {
setCurrentStep((s) => Math.max(s - 1, 0));
announce(`Step ${currentStep} of ${steps.length}: ${steps[currentStep - 1]?.label}`);
}
function announce(message: string) {
if (liveRegionRef.current) {
liveRegionRef.current.textContent = message;
}
}
async function onSubmit(values: OnboardingValues) {
setIsPending(true);
setServerError(null);
const result = await submitOnboarding(values);
setIsPending(false);
if (!result.success) {
setServerError(result.message);
announce(`Submission failed: ${result.message}`);
return;
}
announce("Your account has been created successfully");
// Redirect or show success state here
}
return (
<FormProvider {...methods}>
<div
ref={liveRegionRef}
aria-live="polite"
role="status"
className="sr-only"
/>
<ProgressIndicator steps={steps} currentStep={currentStep} />
<form
onSubmit={methods.handleSubmit(onSubmit)}
noValidate
className="mt-8 space-y-6"
>
{currentStep === 0 && <StepAccount />}
{currentStep === 1 && <StepProfile />}
{currentStep === 2 && <StepReview />}
{serverError && (
<p role="alert" className="text-sm text-red-600">
{serverError}
</p>
)}
<div className="flex justify-between pt-4">
<Button
type="button"
variant="outline"
onClick={goBack}
disabled={currentStep === 0}
>
Back
</Button>
{isLastStep ? (
<Button type="submit" disabled={isPending}>
{isPending ? "Submitting…" : "Complete Setup"}
</Button>
) : (
<Button type="button" onClick={goNext}>
Continue
</Button>
)}
</div>
</form>
</FormProvider>
);
}
A few deliberate choices here are worth calling out:
aria-live="polite"region: this is what makes step transitions announced to screen reader users. Without it, a sighted user sees the new step render, but a screen reader user hears nothing — they're left wondering if their click registered.methods.trigger(fields): this validates only the current step's fields before allowing navigation, rather than validating the whole form prematurely.noValidateon the form tag: we disable native browser validation because we want Zod's messages to be the single source of truth, avoiding duplicate or conflicting error text.
Building a Single Step Component
Each step is a focused, small component. Here's the account step, using Shadcn's Form primitives (which wrap React Hook Form's context and wire up ARIA attributes automatically):
// components/onboarding/step-account.tsx
"use client";
import { useFormContext } from "react-hook-form";
import {
FormField,
FormItem,
FormLabel,
FormControl,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import type { OnboardingValues } from "@/lib/validation/onboarding-schema";
export function StepAccount() {
const { control } = useFormContext<OnboardingValues>();
return (
<fieldset className="space-y-4">
<legend className="text-lg font-semibold">Create your account</legend>
<FormField
control={control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
{...field}
type="email"
autoComplete="email"
placeholder="you@example.com"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input
{...field}
type="password"
autoComplete="new-password"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</fieldset>
);
}
Note the use of <fieldset> and <legend>. This is a small, frequently-skipped detail: wrapping each step's fields in a fieldset gives screen reader users a grouped label ("Create your account, email edit text...") instead of a flat list of unrelated inputs. Shadcn's FormMessage component automatically links errors to inputs via aria-describedby, so you don't have to wire that up by hand.
The Progress Indicator
A visual and accessible progress indicator does double duty: sighted users get a mental map of how much is left, and screen reader users get an announced position via aria-current.
// components/onboarding/progress-indicator.tsx
import { cn } from "@/lib/utils";
interface Step {
id: string;
label: string;
}
export function ProgressIndicator({
steps,
currentStep,
}: {
steps: readonly Step[];
currentStep: number;
}) {
return (
<nav aria-label="Onboarding progress">
<ol className="flex items-center gap-2">
{steps.map((step, index) => {
const isComplete = index < currentStep;
const isCurrent = index === currentStep;
return (
<li key={step.id} className="flex flex-1 items-center gap-2">
<span
aria-current={isCurrent ? "step" : undefined}
className={cn(
"flex h-8 w-8 items-center justify-center rounded-full text-sm font-medium border",
isComplete && "bg-primary text-primary-foreground border-primary",
isCurrent && "border-primary text-primary",
!isComplete && !isCurrent && "border-muted text-muted-foreground"
)}
>
{isComplete ? "✓" : index + 1}
</span>
<span
className={cn(
"hidden sm:block text-sm",
isCurrent ? "font-medium text-foreground" : "text-muted-foreground"
)}
>
{step.label}
</span>
{index < steps.length - 1 && (
<div className="h-px flex-1 bg-border" aria-hidden="true" />
)}
</li>
);
})}
</ol>
</nav>
);
}
Wrapping the whole thing in <nav aria-label="Onboarding progress"> and an ordered list (<ol>) gives it semantic meaning — screen readers will announce "list, 3 items" and let users navigate it like any other list landmark.
Wiring Up the Server Action
Server Actions let us skip the ceremony of defining a REST endpoint, a fetch call, and manual JSON parsing. We define an async function marked "use server", validate again on the server (never trust the client), and return a typed result.
// app/onboarding/actions.ts
"use server";
import { onboardingSchema, type OnboardingValues } from "@/lib/validation/onboarding-schema";
import { db } from "@/lib/db";
type ActionResult =
| { success: true }
| { success: false; message: string };
export async function submitOnboarding(
values: OnboardingValues
): Promise<ActionResult> {
const parsed = onboardingSchema.safeParse(values);
if (!parsed.success) {
return {
success: false,
message: "Some fields are invalid. Please review your entries and try again.",
};
}
try {
const existing = await db.user.findUnique({
where: { email: parsed.data.email },
});
if (existing) {
return {
success: false,
message: "An account with this email already exists.",
};
}
await db.user.create({
data: {
email: parsed.data.email,
fullName: parsed.data.fullName,
role: parsed.data.role,
// password hashing handled elsewhere in your auth layer
},
});
return { success: true };
} catch (error) {
console.error("Onboarding submission failed:", error);
return {
success: false,
message: "Something went wrong on our end. Please try again in a moment.",
};
}
}
Re-validating with onboardingSchema.safeParse server-side is non-negotiable. Client-side validation is a UX convenience; server-side validation is your actual security boundary. Anyone can bypass your React components entirely and POST directly to your action.
Rendering the Page (and Thinking About SEO)
// app/onboarding/page.tsx
import type { Metadata } from "next";
import { FormWizard } from "@/components/onboarding/form-wizard";
export const metadata: Metadata = {
title: "Create Your Account | Acme",
description:
"Set up your Acme account in under two minutes. Add your profile details and get started right away.",
robots: { index: false, follow: false },
};
export default function OnboardingPage() {
return (
<main className="mx-auto max-w-xl px-4 py-12">
<h1 className="text-2xl font-bold tracking-tight">Get started</h1>
<p className="mt-2 text-muted-foreground">
It only takes a couple of minutes to set up your account.
</p>
<FormWizard />
</main>
);
}
A quick but important SEO note: authenticated or transactional flows like onboarding forms generally shouldn't be indexed. Setting robots: { index: false } avoids diluting your crawl budget and keeps low-value, user-specific pages out of search results. Reserve your SEO effort for the marketing and content pages that actually drive organic traffic — the form itself should be fast, accessible, and functionally excellent rather than keyword-optimized.
That said, if the multi-step form lives behind a public-facing landing page (e.g., a "request a demo" flow), make sure that landing page has solid metadata, semantic headings, and fast Core Web Vitals — the form is often the conversion event those efforts are working toward.
Focus Management: The Detail Everyone Forgets
Here's a scenario: a keyboard or screen reader user clicks "Continue." The DOM updates, step two renders — but focus stays wherever it was, often on the now-invisible "Continue" button from step one, or worse, it resets to the top of the <body>. The user has no idea where they are.
Fix this by moving focus to the new step's heading (or first field) on transition:
// Inside StepAccount, StepProfile, StepReview — a shared pattern
import { useEffect, useRef } from "react";
export function StepHeading({ children }: { children: React.ReactNode }) {
const headingRef = useRef<HTMLHeadingElement>(null);
useEffect(() => {
headingRef.current?.focus();
}, []);
return (
<h2 ref={headingRef} tabIndex={-1} className="text-lg font-semibold outline-none">
{children}
</h2>
);
}
tabIndex={-1} makes the heading programmatically focusable without adding it to the natural tab order. Combined with .focus() on mount, this ensures every step transition moves both visual and screen-reader attention to the right place — the same pattern browsers use for client-side route transitions.
Best Practices Checklist
- Validate per step, then re-validate the full payload server-side. Never trust step-level validation as your only gate.
- Persist state across steps using a single form instance (React Hook Form context or a state manager), not separate mounted forms that lose data.
- Use semantic HTML:
<fieldset>,<legend>,<nav>,<ol>— these are free accessibility wins. - Announce transitions with an
aria-liveregion so screen reader users know a step changed. - Move focus to the new step's heading after every transition.
- Disable the submit button during pending states and show a text change (
"Submitting…"), not just a spinner with no text alternative. - Support browser back/forward intentionally — if you don't sync steps to the URL (e.g.,
?step=2), disable or handle the browser back button explicitly rather than letting it silently break state. - Autofill matters: use correct
autoCompleteattributes (email,new-password,given-name, etc.) — this is both a UX and accessibility feature. - Debounce or defer expensive validation (like async email-uniqueness checks) so typing doesn't feel laggy.
Common Mistakes to Avoid
- Re-mounting a fresh form on every step. If each step is its own
useForm()instance, you'll lose data when users go back, and you'll have to bolt on manual state syncing. Use one form instance at the wizard level instead. - Only validating on submit. Users should find out about a bad email format when they blur the field, not three steps later when the whole submission fails.
- Hiding steps with
display: nonebut leaving them in the tab order. If inactive steps remain focusable, keyboard users can tab into invisible fields. Either unmount inactive steps (as we did above) or useinerton hidden containers. - No error summary for screen reader users. A wall of individually-attached field errors is fine visually, but consider also surfacing a summary alert (
role="alert") at the top of the step when validation fails on submit. - Ignoring the server-side validation boundary. Client validation is convenience; skipping server validation is a real vulnerability, especially for anything touching payments or PII.
- Overusing multi-step patterns for short forms. As covered earlier — three fields don't need three steps.
- Forgetting loading and error states for Server Actions. Users need to know a submission is in flight, and they need actionable, specific error messages when it fails — not a silent no-op.
🚀 Pro Tips
- Sync step index to the URL query string (
?step=2) usinguseSearchParamsandrouter.replace. This makes steps shareable/bookmarkable and gives you working browser back/forward navigation for free. - Use
useTransitionaround your Server Action call to get a built-inisPendingboolean without manually managing loading state — it also keeps the UI responsive by marking the update as non-blocking. - Debounce async validation (like checking if an email is already taken) using a short
setTimeoutinside auseEffect, and cancel it on cleanup to avoid race conditions on fast typers. - Test with a screen reader, not just an automated audit tool. Tools like axe or Lighthouse catch maybe 30-40% of real accessibility issues — actually tabbing through your form with VoiceOver or NVDA surfaces problems automated tools can't see, like confusing focus order or unclear announcements.
- Persist partial progress to
localStorage(excluding sensitive fields like passwords) so users who accidentally close the tab don't lose everything. - Keep step transitions fast — under 100ms. Multi-step forms should feel instantaneous; if a step transition requires a network round-trip, show a lightweight skeleton rather than a blank flash.
📌 Key Takeaways
- Multi-step forms are a UX tool for genuinely long or branching forms — don't reach for them by default.
- Split your Zod schema per step, then merge it for a single, authoritative server-side validation pass inside your Server Action.
- Real accessibility requires more than semantic HTML: manage focus explicitly and announce step transitions via an
aria-liveregion. - Shadcn UI's Radix-based primitives handle a lot of ARIA wiring automatically, but you're still responsible for the surrounding structure — fieldsets, legends, and focus order are on you.
- Server Actions simplify the data flow, but they don't replace the need for careful, explicit error handling and pending states in your UI.
Conclusion
A multi-step form is deceptively simple to sketch and genuinely hard to get right. The difference between a form that converts well and one that quietly bleeds users usually isn't the visual design — it's the details: does validation feel timely and specific, does focus land somewhere sensible after each transition, does a screen reader user actually know what step they're on.
Next.js App Router and Server Actions remove a lot of the historical plumbing — no more hand-rolled API routes just to submit a form. Shadcn UI, built on Radix primitives, gives you accessible building blocks instead of making you reimplement ARIA behavior from scratch. But neither of those tools makes your form accessible or usable by default. That's still on you, the developer, to wire together thoughtfully.
Use the patterns in this guide — shared schema architecture, per-step validation, explicit focus management, and live region announcements — as a foundation, and adapt them to your specific flow. Whether you're building a checkout, an onboarding wizard, or a multi-part application form, the underlying architecture holds up.