Multi-step forms break long forms into manageable chunks. They reduce abandonment and improve completion rates. Here is how to build one with React, React Hook Form, and Zod.
Step 1: Install Dependencies
pnpm add react-hook-form @hookform/resolvers zod
Step 2: Define Schemas Per Step
// lib/schemas.ts
import { z } from "zod";
export const personalInfoSchema = z.object({
firstName: z.string().min(1, "First name is required"),
lastName: z.string().min(1, "Last name is required"),
email: z.string().email("Invalid email address"),
phone: z.string().min(10, "Phone number must be at least 10 digits"),
});
export const businessInfoSchema = z.object({
companyName: z.string().min(1, "Company name is required"),
industry: z.string().min(1, "Select an industry"),
website: z.string().url("Invalid URL").or(z.literal("")),
employees: z.enum(["1-10", "11-50", "51-200", "200+"]),
});
export const projectInfoSchema = z.object({
projectType: z.enum(["website", "web-app", "mobile-app", "ecommerce"]),
budget: z.enum(["<5k", "5k-15k", "15k-50k", "50k+"]),
timeline: z.enum(["asap", "1-3months", "3-6months", "flexible"]),
description: z.string().min(20, "Provide at least 20 characters"),
});
export type PersonalInfo = z.infer<typeof personalInfoSchema>;
export type BusinessInfo = z.infer<typeof businessInfoSchema>;
export type ProjectInfo = z.infer<typeof projectInfoSchema>;
export type FormData = PersonalInfo & BusinessInfo & ProjectInfo;
Step 3: Build the Progress Indicator
function StepIndicator({
steps,
currentStep,
}: {
steps: string[];
currentStep: number;
}) {
return (
<div className="flex items-center justify-between">
{steps.map((step, index) => (
<div key={step} className="flex items-center">
<div className="flex flex-col items-center">
<div
className={`flex h-10 w-10 items-center justify-center rounded-full text-sm font-medium ${
index < currentStep
? "bg-green-600 text-white"
: index === currentStep
? "bg-blue-600 text-white"
: "bg-gray-200 text-gray-500 dark:bg-gray-700"
}`}
>
{index < currentStep ? (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
) : (
index + 1
)}
</div>
<span className="mt-2 text-xs text-gray-500">{step}</span>
</div>
{index < steps.length - 1 && (
<div
className={`mx-4 h-0.5 w-16 ${
index < currentStep ? "bg-green-600" : "bg-gray-200 dark:bg-gray-700"
}`}
/>
)}
</div>
))}
</div>
);
}
Step 4: Build Individual Step Components
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import type { PersonalInfo, BusinessInfo, ProjectInfo } from "@/lib/schemas";
import {
personalInfoSchema,
businessInfoSchema,
projectInfoSchema,
} from "@/lib/schemas";
function PersonalInfoStep({
data,
onNext,
}: {
data: Partial<PersonalInfo>;
onNext: (data: PersonalInfo) => void;
}) {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<PersonalInfo>({
resolver: zodResolver(personalInfoSchema),
defaultValues: data,
});
return (
<form onSubmit={handleSubmit(onNext)} className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="block text-sm font-medium">First Name</label>
<input
{...register("firstName")}
className="mt-1 w-full rounded-lg border px-3 py-2 dark:border-gray-600 dark:bg-gray-800"
/>
{errors.firstName && (
<p className="mt-1 text-sm text-red-500">{errors.firstName.message}</p>
)}
</div>
<div>
<label className="block text-sm font-medium">Last Name</label>
<input
{...register("lastName")}
className="mt-1 w-full rounded-lg border px-3 py-2 dark:border-gray-600 dark:bg-gray-800"
/>
{errors.lastName && (
<p className="mt-1 text-sm text-red-500">{errors.lastName.message}</p>
)}
</div>
</div>
<div>
<label className="block text-sm font-medium">Email</label>
<input
{...register("email")}
type="email"
className="mt-1 w-full rounded-lg border px-3 py-2 dark:border-gray-600 dark:bg-gray-800"
/>
{errors.email && (
<p className="mt-1 text-sm text-red-500">{errors.email.message}</p>
)}
</div>
<div>
<label className="block text-sm font-medium">Phone</label>
<input
{...register("phone")}
type="tel"
className="mt-1 w-full rounded-lg border px-3 py-2 dark:border-gray-600 dark:bg-gray-800"
/>
{errors.phone && (
<p className="mt-1 text-sm text-red-500">{errors.phone.message}</p>
)}
</div>
<div className="flex justify-end">
<button
type="submit"
className="rounded-lg bg-blue-600 px-6 py-2 text-white hover:bg-blue-700"
>
Next
</button>
</div>
</form>
);
}
function BusinessInfoStep({
data,
onNext,
onBack,
}: {
data: Partial<BusinessInfo>;
onNext: (data: BusinessInfo) => void;
onBack: () => void;
}) {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<BusinessInfo>({
resolver: zodResolver(businessInfoSchema),
defaultValues: data,
});
return (
<form onSubmit={handleSubmit(onNext)} className="space-y-4">
<div>
<label className="block text-sm font-medium">Company Name</label>
<input
{...register("companyName")}
className="mt-1 w-full rounded-lg border px-3 py-2 dark:border-gray-600 dark:bg-gray-800"
/>
{errors.companyName && (
<p className="mt-1 text-sm text-red-500">{errors.companyName.message}</p>
)}
</div>
<div>
<label className="block text-sm font-medium">Industry</label>
<select
{...register("industry")}
className="mt-1 w-full rounded-lg border px-3 py-2 dark:border-gray-600 dark:bg-gray-800"
>
<option value="">Select an industry</option>
<option value="technology">Technology</option>
<option value="healthcare">Healthcare</option>
<option value="finance">Finance</option>
<option value="retail">Retail</option>
<option value="other">Other</option>
</select>
{errors.industry && (
<p className="mt-1 text-sm text-red-500">{errors.industry.message}</p>
)}
</div>
<div>
<label className="block text-sm font-medium">Website (optional)</label>
<input
{...register("website")}
placeholder="https://example.com"
className="mt-1 w-full rounded-lg border px-3 py-2 dark:border-gray-600 dark:bg-gray-800"
/>
{errors.website && (
<p className="mt-1 text-sm text-red-500">{errors.website.message}</p>
)}
</div>
<div>
<label className="block text-sm font-medium">Number of Employees</label>
<select
{...register("employees")}
className="mt-1 w-full rounded-lg border px-3 py-2 dark:border-gray-600 dark:bg-gray-800"
>
<option value="1-10">1-10</option>
<option value="11-50">11-50</option>
<option value="51-200">51-200</option>
<option value="200+">200+</option>
</select>
</div>
<div className="flex justify-between">
<button
type="button"
onClick={onBack}
className="rounded-lg border px-6 py-2 hover:bg-gray-50 dark:border-gray-600 dark:hover:bg-gray-800"
>
Back
</button>
<button
type="submit"
className="rounded-lg bg-blue-600 px-6 py-2 text-white hover:bg-blue-700"
>
Next
</button>
</div>
</form>
);
}
Step 5: Build the Wizard Container
"use client";
import { useState } from "react";
import type { FormData, PersonalInfo, BusinessInfo, ProjectInfo } from "@/lib/schemas";
const steps = ["Personal Info", "Business Info", "Project Details", "Review"];
export function FormWizard() {
const [currentStep, setCurrentStep] = useState(0);
const [formData, setFormData] = useState<Partial<FormData>>({});
const [isSubmitting, setIsSubmitting] = useState(false);
function handlePersonalInfo(data: PersonalInfo) {
setFormData((prev) => ({ ...prev, ...data }));
setCurrentStep(1);
}
function handleBusinessInfo(data: BusinessInfo) {
setFormData((prev) => ({ ...prev, ...data }));
setCurrentStep(2);
}
function handleProjectInfo(data: ProjectInfo) {
setFormData((prev) => ({ ...prev, ...data }));
setCurrentStep(3);
}
async function handleSubmit() {
setIsSubmitting(true);
try {
const response = await fetch("/api/contact", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(formData),
});
if (!response.ok) throw new Error("Submission failed");
setCurrentStep(4); // Success state
} catch {
alert("Failed to submit. Please try again.");
} finally {
setIsSubmitting(false);
}
}
return (
<div className="mx-auto max-w-2xl">
<StepIndicator steps={steps} currentStep={currentStep} />
<div className="mt-8">
{currentStep === 0 && (
<PersonalInfoStep data={formData} onNext={handlePersonalInfo} />
)}
{currentStep === 1 && (
<BusinessInfoStep
data={formData}
onNext={handleBusinessInfo}
onBack={() => setCurrentStep(0)}
/>
)}
{currentStep === 2 && (
<ProjectInfoStep
data={formData}
onNext={handleProjectInfo}
onBack={() => setCurrentStep(1)}
/>
)}
{currentStep === 3 && (
<ReviewStep
data={formData as FormData}
onSubmit={handleSubmit}
onBack={() => setCurrentStep(2)}
isSubmitting={isSubmitting}
/>
)}
{currentStep === 4 && <SuccessMessage />}
</div>
</div>
);
}
function SuccessMessage() {
return (
<div className="text-center">
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-green-100">
<svg className="h-8 w-8 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
<h3 className="mt-4 text-xl font-semibold">Thank you</h3>
<p className="mt-2 text-gray-500">
We received your information and will be in touch within 24 hours.
</p>
</div>
);
}
UX Best Practices
- Show a progress indicator so users know how many steps remain
- Validate each step before advancing
- Allow users to navigate backwards without losing data
- Persist partial data to localStorage for recovery
- Keep each step focused on one topic
- Show a summary/review step before final submission
Need Custom Forms for Your Business?
We build conversion-optimized forms and lead capture systems for businesses. Contact us to discuss your requirements.