By following this guide, you will build a fully functional multi-step form in Next.js 15 using React Hook Form, Zod schema validation, and persistent step state. The entire setup takes around 60 to 90 minutes and produces a production-ready component you can drop into any SaaS or client-facing application.
What You'll Build
- A three-step form with independent validation per step, preventing progression until each step is complete
- A Zod schema for each step that validates field types, required values, and custom rules
- A visual step progress indicator that reflects current position and completion state
- Form state that persists across steps so users do not lose data when navigating backward
- A final review screen and a simulated async submission with loading and success states
Prerequisites
- Node.js 20 or higher and pnpm 9 installed locally
- A working Next.js 15 project (App Router) created with
create-next-app - Basic familiarity with TypeScript and React hooks
- React Hook Form 7.53+ and Zod 3.23+ (installed in steps below)
Step 1: Install Dependencies
Open your terminal in the project root and install the required packages.
pnpm add react-hook-form zod @hookform/resolvers
React Hook Form handles form state and registration. Zod handles schema-based validation. The @hookform/resolvers package bridges the two. As of October 2026, this combination covers over 70% of production Next.js form implementations according to the NPM download trends for these packages.
Common pitfall: Do not install @types/zod. Zod 3+ ships its own TypeScript types. Installing a separate types package causes version conflicts.
Step 2: Define Your Zod Schemas
Create a new file to hold all step schemas. Keeping schemas in one file makes them easy to reuse and test.
// lib/form-schemas.ts
import { z } from 'zod';
export const stepOneSchema = z.object({
firstName: z.string().min(1, 'First name is required'),
lastName: z.string().min(1, 'Last name is required'),
email: z.string().email('Enter a valid email address'),
});
export const stepTwoSchema = z.object({
companyName: z.string().min(1, 'Company name is required'),
country: z.enum(['AU', 'SG', 'CA', 'US'], {
errorMap: () => ({ message: 'Select a valid country' }),
}),
teamSize: z.coerce.number().min(1, 'Team size must be at least 1'),
});
export const stepThreeSchema = z.object({
plan: z.enum(['starter', 'growth', 'enterprise']),
acceptTerms: z.literal(true, {
errorMap: () => ({ message: 'You must accept the terms' }),
}),
});
export type StepOneData = z.infer;
export type StepTwoData = z.infer;
export type StepThreeData = z.infer;
Using z.coerce.number() on teamSize is important. HTML inputs always return strings, and z.number() alone will reject them. The coerce variant converts the string before validating.
Step 3: Build the Step State Manager
Why manage state outside individual step components?
Each step mounts and unmounts as the user progresses. Without a shared state store, all entered values are lost on unmount. A single parent component holds the collected data across all steps.
Create the parent form page at app/onboarding/page.tsx.
'use client';
import { useState } from 'react';
import StepOne from '@/components/onboarding/StepOne';
import StepTwo from '@/components/onboarding/StepTwo';
import StepThree from '@/components/onboarding/StepThree';
import ReviewStep from '@/components/onboarding/ReviewStep';
import ProgressBar from '@/components/onboarding/ProgressBar';
import { StepOneData, StepTwoData, StepThreeData } from '@/lib/form-schemas';
type FormData = Partial;
export default function OnboardingPage() {
const [step, setStep] = useState(1);
const [formData, setFormData] = useState({});
const [submitted, setSubmitted] = useState(false);
const next = (data: Partial) => {
setFormData((prev) => ({ ...prev, ...data }));
setStep((prev) => prev + 1);
};
const back = () => setStep((prev) => prev - 1);
const handleSubmit = async (data: Partial) => {
const finalData = { ...formData, ...data };
// Replace with your API call
await new Promise((resolve) => setTimeout(resolve, 1200));
console.log('Submitted:', finalData);
setSubmitted(true);
};
if (submitted) {
return Thank you! Your submission was received.
;
}
return (
{step === 1 && }
{step === 2 && }
{step === 3 && }
{step === 4 && }
);
}
The next function merges each step's output into formData before incrementing the step counter. This means every completed step contributes its fields to a single flat object.
Step 4: Build Each Step Component
What does a single step component look like?
Each step component receives defaultValues from the parent, registers a local useForm instance with the matching Zod resolver, and calls onNext with its validated data on submit.
Here is the full StepOne component as a reference. Steps Two and Three follow the same pattern with their own schemas.
// components/onboarding/StepOne.tsx
'use client';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { stepOneSchema, StepOneData } from '@/lib/form-schemas';
type Props = {
defaultValues: Partial;
onNext: (data: StepOneData) => void;
};
export default function StepOne({ defaultValues, onNext }: Props) {
const {
register,
handleSubmit,
formState: { errors },
} = useForm({
resolver: zodResolver(stepOneSchema),
defaultValues,
});
return (
);
}
The noValidate attribute on the form element disables native browser validation. Zod handles all validation logic, so native popups would create a conflicting experience.
Pro tip: Pass defaultValues from the parent into each useForm instance. This re-populates fields when a user navigates backward, which significantly reduces form abandonment in user testing. Businesses targeting markets like Australia and Canada, where multi-page checkout forms are common, see drop-off rates fall by 20 to 35% when back-navigation preserves input.
Step 5: Build the Progress Bar Component
// components/onboarding/ProgressBar.tsx
type Props = { currentStep: number; totalSteps: number };
export default function ProgressBar({ currentStep, totalSteps }: Props) {
const percentage = Math.min((currentStep / totalSteps) * 100, 100);
return (
Step {currentStep} of {totalSteps}
{Math.round(percentage)}% complete
);
}
The transition-all duration-300 class creates a smooth width animation as users move between steps. Keep the transition under 300ms. Anything longer feels sluggish on mobile.
Step 6: Add the Review Screen and Submission
The review screen shows a read-only summary of all collected data before the user submits. This pattern reduces support tickets by catching errors before data is sent to your backend.
// components/onboarding/ReviewStep.tsx
'use client';
import { useState } from 'react';
type Props = {
formData: Record;
onBack: () => void;
onSubmit: (data: Record) => Promise;
};
export default function ReviewStep({ formData, onBack, onSubmit }: Props) {
const [loading, setLoading] = useState(false);
const handleSubmit = async () => {
setLoading(true);
await onSubmit(formData);
setLoading(false);
};
return (
Review Your Details
{Object.entries(formData).map(([key, value]) => (
- {key.replace(/([A-Z])/g, ' $1')}
- {String(value)}
))}
);
}
The disabled state during submission prevents double-clicks. Duplicate form submissions are one of the most common data quality issues in forms that handle signups or payments.
Step 7: Test the Full Flow
What should you verify before shipping?
Run the dev server and walk through the entire flow manually.
pnpm dev
Check each of the following scenarios:
- Submit Step 1 with an empty email. Confirm the Zod error message appears inline.
- Complete Step 1, proceed to Step 2, then click Back. Confirm Step 1 fields are still populated.
- Complete all three steps and confirm the review screen shows accurate data.
- Submit the final form and confirm the success message renders after the simulated delay.
If you plan to write automated tests, use Playwright to assert field-level error messages and step transitions. The Lenka Studio team covers end-to-end form testing patterns in detail when working on client SaaS products, particularly for onboarding flows with compliance requirements common in Singapore and Canadian markets.
Frequently Asked Questions
Can I use this pattern with Next.js App Router server actions?
Yes. Replace the simulated await new Promise call in the parent's handleSubmit with a call to a Next.js server action. The form state still lives on the client, but the submission itself can run server-side. Mark the action with 'use server' and call it from the client component.
What if a user refreshes the page and loses all their progress?
The current implementation stores state in React memory only. To persist across refreshes, serialize formData and step to sessionStorage inside a useEffect and rehydrate on mount. For sensitive data like payment or health information, avoid client-side storage entirely and use a server-side session instead.
How do I handle conditional steps, for example skipping Step 2 for solo users?
Add a condition to the next function in the parent. Check a field value in the merged formData and call setStep((prev) => prev + 2) instead of prev + 1. Update the ProgressBar to accept a visibleSteps prop rather than relying on a fixed total.
Does React Hook Form re-render the whole form on every keystroke?
No. React Hook Form uses uncontrolled inputs by default, which means it does not trigger a re-render on every keystroke. Validation only runs on submit (or on blur if you set mode: 'onBlur' in useForm). This makes it significantly faster than controlled form libraries for large forms.
Can I replace Zod with Yup or Valibot?
Yes. The @hookform/resolvers package ships adapters for Yup, Valibot, Joi, and several other schema libraries. Swap the resolver import and update your schema syntax. The rest of the component code stays the same. Valibot is worth considering if bundle size is a concern, as its tree-shakeable API can reduce validation bundle weight by roughly 60% compared to Zod.
Next Steps
From here, you can extend this form in several practical directions. Connect the submit handler to a real API route or server action. Add field-level async validation, for example checking whether an email is already registered. Integrate Stripe Elements into Step 3 for a payment step using the Stripe API. Add Framer Motion transitions between steps to improve perceived responsiveness.
If your product's onboarding flow is a core part of your conversion funnel, the structure of these steps matters as much as the code behind them. At Lenka Studio, we regularly audit onboarding flows for SaaS products across Australia, Singapore, and the US, and the most common issue is asking for too much information too early. Keep Step 1 to the minimum required to create an account.
If you want a second opinion on your form architecture or need help building a production-grade onboarding flow for your product, get in touch with the Lenka Studio team. We can review your current setup or build the full flow from scratch.




