Feature spec
IntermediateCategory
forms-data
Build with AI
6-12 hours with Lovable or V0
Custom build
3-5 days custom dev
Running cost
$0/mo up to ~1,000 users; $25-50/mo at 10,000 users
Works on
Everything it takes to ship a Multi-Step Form Wizard — parts, prompts, and real costs.
A multi-step form wizard needs five pieces: a step orchestrator (React state machine), isolated step components each with their own react-hook-form + zod schema, a step indicator UI, localStorage persistence so refresh doesn't lose progress, and a final submission handler. With Lovable or V0 you can ship a working wizard in 6-12 hours for $0/month — all libraries are free and Supabase free tier covers submission storage.
What a Multi-Step Form Wizard Actually Is
A multi-step form wizard breaks a long form into sequential steps — onboarding, insurance intake, checkout, lead capture — so users aren't overwhelmed by a single page of 20 fields. The core product decisions are: how many steps, whether validation blocks forward navigation or shows warnings only, whether completed steps are clickable for editing, and whether the wizard must work without login (anonymous). The most common build mistake is using a single monolithic useForm() for the entire wizard instead of isolated per-step schemas. With a single form, navigating back to step 1 from step 3 unmounts the step 1 component and loses all its data. Per-step schemas with parent-level state accumulation is the correct pattern, and it requires explicit prompting.
What users consider table stakes in 2026
- Step indicator shows current step, completed steps, and total count ('Step 2 of 5') — completed steps are visually distinct from upcoming steps
- Per-step validation: the Next button is disabled or triggers inline errors until all required fields in the current step are valid
- Back navigation always restores previously entered values — going back never clears data
- Form data persists to localStorage so a page refresh or accidental close restores progress
- A review step before final submission shows all entered values in a readable summary
- Submit button appears only on the final step — intermediate Next buttons must never trigger a database write
Anatomy of the Feature
Six components. The per-step form isolation and localStorage hydration are where almost every first AI build fails. Both require explicit prompting to get right.
Step orchestrator
UIA parent React component holding currentStep (integer), direction ('forward' | 'back' for animation), and formData (an object accumulating all step values). For simple linear wizards, useState is sufficient. For complex wizards with branching logic or many steps, useReducer or a Zustand store prevents prop-drilling. The orchestrator renders the active step component and owns the Next and Back button logic.
Note: The formData object in the orchestrator is the single source of truth. Each step receives its slice of formData as defaultValues and reports back via an onStepComplete(stepData) callback. Never rely on the step component's internal form state surviving unmount.
Step components
UIEach step is a self-contained React component with its own useForm() + zod schema. It receives defaultValues={formData.stepN} and an onStepComplete callback. When the user clicks Next, the step validates its schema; on success, it calls onStepComplete(stepData) which merges the step's data into the parent formData before advancing currentStep. Steps never submit directly to the server — they only hand data to the orchestrator.
Note: Isolation is the key constraint: if any step imports state from another step's useForm(), the architecture breaks. Each step's zod schema should be narrow — only the fields visible in that step.
Step indicator
UIA horizontal stepper using a shadcn/ui Steps component or a custom implementation: a row of numbered circles with step labels below them. Each circle has three states: completed (filled, checkmark icon), active (filled with accent color, current step number), and upcoming (outlined, step number). On mobile, the full stepper collapses to a simple 'Step 2 of 5' text to save horizontal space.
Note: Make completed steps clickable only if your design explicitly supports non-linear editing. Clickable completed steps add significant complexity: any field the user edits in step 2 may invalidate a review step that assumed a different value.
localStorage persistence
DataA useEffect in the orchestrator serializes formData to localStorage on every change using JSON.stringify(). The key is namespaced and versioned (e.g., 'wizard_onboarding_v1'). On mount, a separate useEffect reads from localStorage and pre-fills formData. A version key ensures that if the wizard schema changes (fields added or removed), old cached data is discarded rather than causing type errors.
Note: In Next.js, localStorage reads must happen in a useEffect with no dependencies — never during render. Reading localStorage synchronously during render causes a server/client hydration mismatch that produces React hydration error warnings in the console.
Step transition animation
UIFramer Motion's AnimatePresence wrapping the active step component, with direction-aware slide transitions: forward navigation slides the new step in from the right, back navigation slides it in from the left. The direction state in the orchestrator (set before currentStep changes) drives the animation variant. Each step is wrapped in a motion.div with a unique key={currentStep} so AnimatePresence unmounts the exiting step before mounting the entering one.
Note: Use mode='wait' on AnimatePresence (the Framer Motion v10+ replacement for exitBeforeEnter). Without mode='wait', both the exiting and entering step are in the DOM simultaneously, causing react-hook-form to register duplicate field names.
Final submission handler
BackendOn the final step's Submit button click, the complete formData object is validated against a combined zod schema (the union of all step schemas). On success, it is sent to a Supabase insert or a Next.js Server Action. A wizard_submissions table stores the complete payload as a JSONB column. On submit, the isSubmitting state is set to true and the Submit button is disabled to prevent double submission. On success, the localStorage key is cleared and the user is redirected to a confirmation step.
Note: Only write to the database on final submission, not between steps. Per-step database writes create abandoned partial records that pollute the submissions table. For authenticated users who need cross-device resume, use a separate wizard_drafts table and move the record to wizard_submissions on completion.
The data model
One table covers completed wizard submissions. Run this in the Supabase SQL editor — the RLS covers both authenticated user submissions and anonymous lead capture wizards:
1create table public.wizard_submissions (2 id uuid primary key default gen_random_uuid(),3 user_id uuid references auth.users(id) on delete set null,4 form_type text not null,5 step_data jsonb not null,6 submitted_at timestamptz not null default now(),7 status text not null default 'complete',8 metadata jsonb default '{}'9);1011-- Optional: draft table for authenticated users who need cross-device resume12create table public.wizard_drafts (13 id uuid primary key default gen_random_uuid(),14 user_id uuid references auth.users(id) on delete cascade not null,15 form_type text not null,16 step_data jsonb not null,17 current_step int not null default 0,18 last_updated_at timestamptz not null default now(),19 unique (user_id, form_type)20);2122alter table public.wizard_submissions enable row level security;23alter table public.wizard_drafts enable row level security;2425-- Authenticated users can see and insert their own submissions26create policy "Wizard submissions owner read"27 on public.wizard_submissions for select28 using (auth.uid() = user_id);2930-- Anonymous wizards (lead capture): anyone can insert, no one can read31create policy "Wizard submissions anon insert"32 on public.wizard_submissions for insert33 with check (true);3435-- Drafts: authenticated users own their draft36create policy "Wizard drafts user access"37 on public.wizard_drafts for all38 using (auth.uid() = user_id)39 with check (auth.uid() = user_id);4041-- Index for admin queries filtered by form_type42create index wizard_submissions_form_type_idx43 on public.wizard_submissions (form_type, submitted_at desc);4445-- Index for draft lookup by user + form46create index wizard_drafts_user_form_idx47 on public.wizard_drafts (user_id, form_type);Heads up: The wizard_submissions.step_data JSONB column stores the complete merged formData object. Add a form_type text column so you can support multiple wizard types in the same table (onboarding, checkout, support intake). For admin reporting, add a Supabase view that extracts specific JSONB keys from step_data as typed columns.
Build it — pick your path
Each tool fits this feature differently. Switch tabs to compare fit, time, the exact steps, and a copy-paste prompt.
V0 is the strongest path for multi-step forms. Next.js + shadcn/ui generates clean per-step component files, Design Mode adjusts the step indicator without credit cost, and Server Actions handle final submission cleanly.
Step by step
- 1Prompt V0 with the full spec below; use the Vars panel to add NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY for submission storage
- 2After generation, verify the localStorage hydration: look for a useEffect with an empty dependency array that reads from localStorage and calls setFormData — if the localStorage read is inside the component body (not in a useEffect), it will cause a Next.js SSR hydration error
- 3Test the Framer Motion animation: click Next and Back rapidly; if both step components are visible simultaneously during the transition, mode='wait' is missing from AnimatePresence — add a follow-up prompt
- 4Run the SQL from this page in the Supabase SQL editor to create the wizard_submissions table before testing final submission
Build a multi-step form wizard in Next.js App Router. Component files: (1) WizardOrchestrator (client component) — manages currentStep (0-3), direction ('forward'|'back'), formData (accumulated step values); provides these to step components via props. (2) StepIndicator (client component) — receives currentStep and totalSteps; renders horizontal row of numbered circles with step labels; completed steps (index < currentStep) show a check icon; active step is highlighted; on viewport width below 768px render 'Step {currentStep+1} of {totalSteps}' text only. (3) Four step components (each is a client component): StepUserInfo, StepContact, StepPreferences, StepReview. Each step: const form = useForm({resolver: zodResolver(stepSchema), defaultValues: props.defaultValues}); submit handler calls props.onStepComplete(form.getValues()) only after successful validation; shows inline errors via form.formState.errors. (4) WizardLayout wraps the orchestrator in a centered card with max-width 640px. localStorage: in WizardOrchestrator, a useEffect with [] deps reads 'wizard_v1' from localStorage and calls setFormData (never read localStorage during render to avoid SSR mismatch); a separate useEffect with [formData] dep writes JSON.stringify(formData) to 'wizard_v1' on every change; clear on submit success. Framer Motion: AnimatePresence mode='wait' wraps the active step; each step is a motion.div with key={currentStep}; forward animation: initial={x: '100%'}, animate={x: 0}, exit={x: '-100%'}; back animation: initial={x: '-100%'}, animate={x: 0}, exit={x: '100%'}; transition 200ms ease-in-out. Final submission: Server Action that validates the complete formData with a combined zod schema, then inserts to Supabase wizard_submissions; return {success, error}; on success clear localStorage and advance to a Confirmation step (step 4, not counted in indicator).Where this path bites
- Supabase tables must be created manually — run the SQL schema from this page in the Supabase SQL editor before testing submission
- localStorage hydration in Next.js requires useEffect — if V0 reads localStorage during render, you will see 'Text content does not match server-rendered HTML' errors; correct it with a follow-up prompt specifying the useEffect pattern
- Tailwind v3/v4 conflict can affect AnimatePresence transition styles if the project was created with create-next-app defaults — if transitions appear broken, add explicit inline style for the transform
- Resend email confirmation after submission requires a separate prompt and API key setup in Vercel env vars; it is not included in the base wizard build
Third-party services you'll need
A multi-step form wizard uses only free libraries and Supabase for persistence. Resend is optional for confirmation emails:
| Service | What it does | Free tier | Paid from |
|---|---|---|---|
| react-hook-form + zod | Per-step form validation with isolated schemas; combined schema validation on final submission | Free, open-source (MIT) | Free |
| Framer Motion | Direction-aware step transition animations with AnimatePresence | Free, open-source (MIT) | Free |
| Supabase | Submission storage (wizard_submissions table) and optional draft persistence for cross-device resume | Free (2 projects, 500MB DB) | $25/mo Pro (8GB DB) |
| Resend (optional) | Confirmation email after wizard submission with a summary of entered values | Free (3K emails/mo) | $20/mo Pro (50K emails/mo) (approx) |
Swipe the table sideways to see pricing.
What it costs to run
Drag through the tiers to see how your monthly bill scales with users — no surprises later.
Estimated monthly running cost
Entirely client-side state management. Supabase free tier handles submission storage comfortably. All libraries are free. Resend free tier covers confirmation emails.
Estimates use verified 2026 pricing and assume typical usage per user. Your real bill depends on activity, storage, and third-party plans.
What breaks when AI tools build this
The failures people actually hit on their first build — the symptom, why it happens, and the exact fix.
Previous step data disappears when navigating back
Symptom: This is the most common multi-step form bug. Each step mounts its own useForm() which resets to empty defaultValues when the component re-mounts. If the step component does not receive its previous values as defaultValues, navigating from step 3 back to step 1 shows a blank form. AI tools frequently generate a single parent useForm() as a fix, but this prevents per-step isolated validation.
Fix: Keep formData as a parent-level state object in the orchestrator. Each step receives defaultValues={formData.step1} (or the relevant slice). When the user clicks Next and the step validates successfully, call onStepComplete(form.getValues()) to merge the step's values back into the parent formData. The parent formData is the single source of truth; the step's internal form state only lives for the duration that step is mounted.
AnimatePresence renders both steps simultaneously, breaking validation
Symptom: During a step transition, Framer Motion mounts the incoming step while the outgoing step is still animating out. If mode='wait' is missing from AnimatePresence, both step components are in the DOM at the same time. Two step components with overlapping field names (e.g., both have an 'email' field) cause react-hook-form to register duplicate fields, leading to validation conflicts where errors from the wrong step appear.
Fix: Add mode='wait' to the AnimatePresence component. This tells Framer Motion to fully unmount the exiting step (complete the exit animation) before mounting the entering step. Also add a unique key={currentStep} to each motion.div wrapper — AnimatePresence uses the key to identify which component is entering and which is exiting.
localStorage hydration causes React SSR hydration errors in Next.js
Symptom: V0 and Lovable (when targeting Next.js) frequently generate code that reads from localStorage during the component render cycle — either in useState's initializer function or in a useMemo. Next.js server renders the component with empty formData, but the client sees pre-filled formData from localStorage, producing a mismatch that React logs as 'Text content does not match server-rendered HTML' and can cause subtle rendering inconsistencies.
Fix: Initialize formData as an empty object (or null) on both server and client. In a separate useEffect with an empty dependency array, read from localStorage and call setFormData. This useEffect only runs on the client after hydration, so the server and client agree on the initial render. Never call localStorage in useMemo, useState initializer, or directly during render.
Double submission on slow network
Symptom: On the final step, the user clicks Submit. The Server Action or Supabase insert takes 2 seconds on a slow connection. The button appears responsive (no feedback), so the user clicks again. Two identical submissions appear in the wizard_submissions table. This is particularly problematic for lead capture and intake forms where duplicate records cause workflow confusion.
Fix: Set an isSubmitting boolean state to true when Submit is clicked. Pass isSubmitting as the disabled prop and a loading spinner condition to the Submit button. Reset isSubmitting to false only on error — not on success, because you want to prevent re-submission after the success screen is shown. For additional safety, check for an existing record with the same user_id and form_type submitted within the last 30 seconds before inserting.
Abandoned partial submissions fill the database
Symptom: Some implementations save wizard state to Supabase after each step to enable cross-device resume. This creates a partial record at step 1 that is never completed if the user abandons the wizard. At scale, thousands of these partial records fill the wizard_submissions table, distort completion rate metrics, and create GDPR data retention obligations for data that was never actually submitted.
Fix: Use localStorage for progress persistence in anonymous and most authenticated wizards — only write to Supabase on final submission. For authenticated users who explicitly need cross-device resume, save to a separate wizard_drafts table (not wizard_submissions) and move the record to wizard_submissions atomically only on final submit. Run a nightly pg_cron job to delete wizard_drafts older than 30 days.
Best practices
Use isolated per-step useForm() + zod schemas — one monolithic useForm() for the entire wizard is the root cause of the most common wizard bug (back navigation losing data)
Keep formData in parent-level useState as the single source of truth — treat step components as pure UI that receive data and report completion, never holding state that must survive unmount
Always include a version key in your localStorage namespace ('wizard_onboarding_v1') — when you add or remove steps post-launch, old cached data from v1 will crash a v2 wizard without the version increment
Only write to Supabase on final submission, not between steps — per-step database writes create abandoned partial records; use localStorage for in-progress state
Disable the Submit button immediately on click and re-enable only on error — never on success, to prevent accidental double submission on mobile where users tap twice
Show a review step before the final Submit button — users who see a summary catch their own mistakes, reducing support requests from incorrect submissions
On mobile, collapse the step indicator to 'Step X of Y' text — a 5-step horizontal stepper with labels overflows on screens under 375px wide
Test back navigation at every step before shipping — navigate forward to the last step, then click Back all the way to step 1 and verify every field still holds its entered value
When You Need Custom Development
A linear multi-step wizard is well-covered by Lovable and V0. Custom development is warranted when the wizard has requirements that go beyond a static step sequence:
- Branching wizard: the path through steps changes based on answers (for example, 'Are you an individual or business?' shows an entirely different step 3) — best modeled with XState rather than if/else blocks in React
- Multi-user handoff: different wizard steps are completed by different people — a requester fills steps 1-2, an approver completes steps 3-4 via a separate link with pre-populated data
- Resume from any device: wizard state must persist server-side (not just localStorage) so users can start on mobile and finish on desktop days later
- Regulatory compliance: each step change must be timestamped and stored in an audit log — required for insurance underwriting, medical intake, or financial services workflows
RapidDev builds these features for production
Real auth, database, and payments — fixed price, delivered in weeks. You own 100% of the code.
Frequently asked questions
How do I build a multi-step form in React that validates each step before proceeding?
Use a separate useForm() with a step-specific zod schema for each step component. When the user clicks Next, call form.handleSubmit() which triggers zod validation for the current step's fields only. If validation passes, the handleSubmit callback receives the validated data — call your onStepComplete(data) prop to pass it to the parent orchestrator, then advance currentStep. If validation fails, react-hook-form automatically sets form.formState.errors and the Next button stays on the current step. Never use a single useForm() for the entire wizard — it prevents isolated per-step validation and causes back-navigation data loss.
How do I prevent users from losing form data if they refresh the page mid-wizard?
Write formData to localStorage on every change using a useEffect that watches the formData state. Use a namespaced, versioned key like 'wizard_onboarding_v1'. On component mount, a separate useEffect (empty dependency array, runs once on client) reads from localStorage and calls setFormData to pre-fill all steps. In Next.js, never read localStorage during render or in useState's initializer — this causes SSR hydration mismatches. Clear the localStorage key after successful submission so returning users start fresh.
How do I show a progress bar or step indicator for a multi-step form?
Build a StepIndicator component that receives currentStep (0-indexed) and totalSteps. Render a row of numbered circles: index < currentStep is 'completed' (show a check icon), index === currentStep is 'active' (accent color), index > currentStep is 'upcoming' (outlined). On mobile widths under 768px, replace the full stepper with a simple 'Step {currentStep + 1} of {totalSteps}' text element — a 5-step horizontal stepper with labels overflows on phones. shadcn/ui has a Steps component, or you can build a simple custom implementation in under 30 lines.
Can I let users go back to previous steps without losing their answers?
Yes — this is the central architecture decision. Each step component must receive its previously entered values as defaultValues from the parent orchestrator's formData object. When the user clicks Next, the step calls onStepComplete(form.getValues()) to merge its values back into the parent. When the user clicks Back and the step re-mounts, it receives defaultValues={formData.step1} and pre-fills with the saved values. If back navigation clears the form, the step is reading from its own internal state instead of the parent's formData.
How do I animate the transition between form steps?
Use Framer Motion's AnimatePresence with mode='wait' wrapping the active step. Each step is wrapped in a motion.div with key={currentStep}. Track a direction state ('forward' | 'back') in the orchestrator — set it before changing currentStep. Forward: new step slides in from the right (initial: x: '100%'); back: new step slides in from the left (initial: x: '-100%'). The exit animation is the reverse. Set the transition to 200ms ease-in-out. Without mode='wait', both steps are in the DOM simultaneously during the transition, causing react-hook-form to register duplicate field names.
How do I submit all multi-step form data in one API call at the end?
Accumulate all step data in a parent-level formData object as users complete each step. Only on the final step's Submit button click, validate the entire formData with a combined zod schema (the intersection or merge of all step schemas) and send it to Supabase as a single insert or to a Next.js Server Action. This approach avoids abandoned partial records and keeps submission handling simple. Store the complete formData as a JSONB column (step_data) alongside a form_type column in the wizard_submissions table.
What's the best way to handle validation in a multi-step form with react-hook-form?
Give each step its own useForm() initialized with a step-specific zod schema via zodResolver. This means the zod schema for step 1 only validates step 1 fields — email, name — and the step 2 schema only validates step 2 fields — address, city, state. When the user clicks Next, trigger validation for the current step only; errors appear inline under the relevant fields. On the final Submit, re-validate the entire combined payload before writing to the database using a merged schema that includes all step schemas. This prevents a step from being 'completed' with invalid data that the user thought was valid.
Should I save form data to the database between steps or only on final submit?
For most wizards, save only on final submission and use localStorage for in-progress state. Saving to the database between steps creates abandoned partial records from users who never complete the wizard — at 10,000 users these quickly pollute your submissions table and complicate GDPR data retention. The exception is when authenticated users explicitly need cross-device resume (start on mobile, finish on desktop). In that case, upsert to a separate wizard_drafts table after each step and move to wizard_submissions atomically on final submission. Run a cleanup job to delete drafts older than 30 days.
Need this feature production-ready?
RapidDev builds a multi-step form wizard into real apps — auth, database, payments — at $13K–$25K.
Book a free consultation30-min call. No commitment.