Build a Multi-Step Form in Next.js with Data Persistence
Learn how to create a multi-step form with proper data persistence using Next.js, React Hook Form, Zod validation, and Simple Store
Build a Multi-Step Form in Next.js with Data Persistence
Multi-step forms are everywhere - from signup flows to surveys to checkout processes. In this tutorial, you'll learn how to build a production-ready multi-step form with proper data persistence, validation, and state management.
What You'll Build
A 2-step onboarding form where:
- ✅ Step 1: Collects user's basic information
- ✅ Step 2: Collects user's preferences
- ✅ Data persists when navigating between steps
- ✅ Form validation on each step
- ✅ Clean navigation between steps
Prerequisites
- Basic knowledge of React and Next.js
- Node.js installed on your machine
- Familiarity with TypeScript (helpful but not required)
Tech Stack
- Next.js 14+ - React framework
- React Hook Form - Form state management
- Zod - Schema validation
- Simple Store - Lightweight state management for data persistence
- Shadcn UI - Beautiful, accessible components
Step 1: Project Setup
Create a Next.js Project
pnpm create next-app@latest multi-step-form
cd multi-step-form
Choose the following options:
- ✅ TypeScript
- ✅ ESLint
- ✅ Tailwind CSS
- ✅ App Router
- ❌ Turbopack (optional)
Install Dependencies
# Core dependencies
npm install react-hook-form @hookform/resolvers zod
npm install @simplestack/store
# Shadcn UI setup
npx shadcn@latest initWhen prompted for Shadcn configuration, accept the defaults.
Install Shadcn Components
pnpm dlx shadcn@latest add button input label
Step 2: Set Up Simple Store
Simple Store is a lightweight state management library that makes data persistence easy. Let's create our store structure.
Create Store File
Create store/survey.ts:
import { createStore } from "@simplestack/store";
// Define the shape of our form data
interface FormData {
stepOne: {
fullName: string;
email: string;
phone: string;
};
stepTwo: {
occupation: string;
experience: string;
};
}
// Initialize store with empty data
export const userStore = createStore<FormData>({
stepOne: {
fullName: "",
email: "",
phone: "",
},
stepTwo: {
occupation: "",
experience: "",
},
});
// Store to track current step (1 or 2)
export const stepsStore = createStore<number>(1);What's happening here?
- We define the structure of our form data using TypeScript interfaces
userStoreholds all form data from both stepsstepsStoretracks which step the user is currently oncreateStore()creates a reactive store that components can subscribe to
Step 3: Create Form Components Directory
Create the following folder structure:
components/
├── steps/
│ ├── step-one.tsx
│ └── step-two.tsx
├── form.tsx
└── NavigationButtons.tsx
Step 4: Build Step One Component
Create components/steps/step-one.tsx:
"use client";
import * as React from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import * as z from "zod";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { stepsStore, userStore } from "@/store/survey";
import { useStoreValue } from "@simplestack/store/react";
// Define validation schema with Zod
const formSchema = z.object({
fullName: z.string().min(3, "Name must be at least 3 characters"),
email: z.string().email("Invalid email address"),
phone: z.string().min(10, "Phone must be at least 10 digits"),
});
export default function StepOne() {
// Subscribe to store data - this makes the component reactive
const stepOneData = useStoreValue(userStore.select("stepOne"));
// Initialize form with React Hook Form
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
fullName: "",
email: "",
phone: "",
},
});
// Update form when store data changes (important for back navigation!)
React.useEffect(() => {
form.reset({
fullName: stepOneData.fullName || "",
email: stepOneData.email || "",
phone: stepOneData.phone || "",
});
}, [stepOneData, form]);
// Get store selector for saving data
const stepOneStore = userStore.select("stepOne");
function onSubmit(data: z.infer<typeof formSchema>) {
console.log("Step 1 Data:", data);
// Save data to store
stepOneStore.set(data);
// Navigate to next step
stepsStore.set(2);
}
return (
<div className="max-w-md mx-auto p-6">
<h2 className="text-2xl font-bold mb-2">Welcome! 👋</h2>
<p className="text-gray-600 mb-6">Let's start with your basic information</p>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
{/* Full Name Field */}
<div>
<Label htmlFor="fullName">Full Name *</Label>
<Input
id="fullName"
{...form.register("fullName")}
placeholder="John Doe"
/>
{form.formState.errors.fullName && (
<p className="text-red-500 text-sm mt-1">
{form.formState.errors.fullName.message}
</p>
)}
</div>
{/* Email Field */}
<div>
<Label htmlFor="email">Email Address *</Label>
<Input
id="email"
type="email"
{...form.register("email")}
placeholder="john@example.com"
/>
{form.formState.errors.email && (
<p className="text-red-500 text-sm mt-1">
{form.formState.errors.email.message}
</p>
)}
</div>
{/* Phone Field */}
<div>
<Label htmlFor="phone">Phone Number *</Label>
<Input
id="phone"
{...form.register("phone")}
placeholder="+1234567890"
/>
{form.formState.errors.phone && (
<p className="text-red-500 text-sm mt-1">
{form.formState.errors.phone.message}
</p>
)}
</div>
<Button type="submit" className="w-full">
Continue to Next Step
</Button>
</form>
</div>
);
}Key Concepts Explained
1. useStoreValue Hook
const stepOneData = useStoreValue(userStore.select("stepOne"));- This subscribes to the store and re-renders when data changes
- Essential for data persistence when navigating back
2. useEffect for Form Sync
React.useEffect(() => {
form.reset({
/* data from store */
});
}, [stepOneData, form]);- Watches for changes in store data
- Updates form fields when user navigates back
- Without this, the form would be empty even if data exists in the store
3. Saving to Store
stepOneStore.set(data);- Saves form data to the store
- Data persists even when navigating to other steps
Step 5: Build Step Two Component
Create components/steps/step-two.tsx:
"use client";
import * as React from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import * as z from "zod";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { stepsStore, userStore } from "@/store/survey";
import { useStoreValue } from "@simplestack/store/react";
const formSchema = z.object({
occupation: z.string().min(2, "Occupation is required"),
experience: z.string().min(1, "Experience level is required"),
});
export default function StepTwo() {
const stepTwoData = useStoreValue(userStore.select("stepTwo"));
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
occupation: "",
experience: "",
},
});
// Sync form with store data
React.useEffect(() => {
form.reset({
occupation: stepTwoData.occupation || "",
experience: stepTwoData.experience || "",
});
}, [stepTwoData, form]);
const stepTwoStore = userStore.select("stepTwo");
function onSubmit(data: z.infer<typeof formSchema>) {
console.log("Step 2 Data:", data);
// Save data to store
stepTwoStore.set(data);
// Get all data from store
const allData = userStore.get();
console.log("Complete Form Data:", allData);
// Here you would typically submit to your backend
alert("Form submitted successfully! Check console for data.");
}
function goBack() {
stepsStore.set(1);
}
return (
<div className="max-w-md mx-auto p-6">
<h2 className="text-2xl font-bold mb-2">Almost Done! 🎉</h2>
<p className="text-gray-600 mb-6">Tell us about your work</p>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
{/* Occupation Field */}
<div>
<Label htmlFor="occupation">Occupation *</Label>
<Input
id="occupation"
{...form.register("occupation")}
placeholder="Software Developer"
/>
{form.formState.errors.occupation && (
<p className="text-red-500 text-sm mt-1">
{form.formState.errors.occupation.message}
</p>
)}
</div>
{/* Experience Field */}
<div>
<Label htmlFor="experience">Years of Experience *</Label>
<Input
id="experience"
{...form.register("experience")}
placeholder="5 years"
/>
{form.formState.errors.experience && (
<p className="text-red-500 text-sm mt-1">
{form.formState.errors.experience.message}
</p>
)}
</div>
{/* Navigation Buttons */}
<div className="flex gap-3">
<Button
type="button"
variant="outline"
onClick={goBack}
className="flex-1"
>
Previous
</Button>
<Button type="submit" className="flex-1">
Submit
</Button>
</div>
</form>
</div>
);
}Step 6: Create Main Form Component
Create components/form.tsx:
"use client";
import StepOne from "./steps/step-one";
import StepTwo from "./steps/step-two";
import { stepsStore } from "@/store/survey";
import { useStoreValue } from "@simplestack/store/react";
export default function MultiStepForm() {
// Subscribe to current step
const currentStep = useStoreValue(stepsStore);
// Render the appropriate step
function renderStep() {
if (currentStep === 1) {
return <StepOne />;
} else if (currentStep === 2) {
return <StepTwo />;
}
return null;
}
return (
<div className="min-h-screen bg-gray-50 py-12">
{/* Progress Indicator */}
<div className="max-w-md mx-auto mb-8">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium">Step {currentStep} of 2</span>
<span className="text-sm text-gray-500">{currentStep === 1 ? "Basic Info" : "Work Details"}</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
style={{ width: `${(currentStep / 2) * 100}%` }}
/>
</div>
</div>
{/* Render Current Step */}
{renderStep()}
</div>
);
}Step 7: Use the Form in Your App
Update app/page.tsx:
import MultiStepForm from "@/components/form";
export default function Home() {
return <MultiStepForm />;
}How It All Works Together
Data Flow Diagram
User fills form → Validation → Save to Simple Store → Navigate to next step
↓ ↓
Show errors Load data from store
↓
Populate form with useEffect
The Magic of Data Persistence
When you click "Previous" to go back:
- stepsStore updates → Component re-renders with new step
- useStoreValue triggers → Component receives latest store data
- useEffect runs →
form.reset()populates all fields - User sees their data → All inputs show previously entered values
Without useEffect (Won't Work!)
// ❌ This only reads once on mount
const stepData = userStore.select("stepOne").get();
const form = useForm({
defaultValues: stepData, // Won't update when navigating back!
});With useEffect (Works!)
// ✅ This updates reactively
const stepData = useStoreValue(userStore.select("stepOne"));
useEffect(() => {
form.reset(stepData); // Updates when navigating back!
}, [stepData]);Testing Your Form
-
Start the dev server:
pnpm dev -
Open
http://localhost:3000 -
Test data persistence:
- Fill out Step 1 with your info
- Click "Continue to Next Step"
- Fill out Step 2
- Click "Previous" button
- Verify: All Step 1 data is still there! ✅
- Click "Continue" again
- Verify: Step 2 data is also preserved! ✅
-
Check the console:
- Open DevTools (F12)
- Submit the final form
- See all collected data logged
Common Issues & Solutions
Issue 1: Data doesn't persist when going back
Problem: Using .get() instead of useStoreValue
// ❌ Wrong
const data = userStore.select("stepOne").get();Solution: Use the hook
// ✅ Correct
const data = useStoreValue(userStore.select("stepOne"));Issue 2: Form doesn't update with store data
Problem: Missing useEffect to sync form
Solution: Add the effect
useEffect(() => {
form.reset(storeData);
}, [storeData, form]);Issue 3: TypeScript errors
Problem: Mismatched types between store and form schema
Solution: Ensure Zod schema matches store interface:
// Store interface
interface StepOne {
fullName: string;
email: string;
}
// Matching Zod schema
const schema = z.object({
fullName: z.string(),
email: z.string().email(),
});Next Steps & Enhancements
Now that you have a working multi-step form, here are some enhancements:
1. Add More Steps
Simply create more step components following the same pattern:
step-three.tsx,step-four.tsx, etc.- Update the store interface
- Update the
renderStep()function
2. Add localStorage Persistence
// Save to localStorage on store update
userStore.subscribe((data) => {
localStorage.setItem("formData", JSON.stringify(data));
});
// Load from localStorage on mount
const savedData = localStorage.getItem("formData");
if (savedData) {
userStore.set(JSON.parse(savedData));
}3. Add Form Submission to Backend
async function onSubmit(data) {
const allData = userStore.get();
try {
const response = await fetch("/api/submit", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(allData),
});
if (response.ok) {
alert("Success!");
}
} catch (error) {
console.error("Submission failed:", error);
}
}4. Add Step Validation
Prevent moving to next step if current step is incomplete:
function goToNextStep() {
const isValid = form.formState.isValid;
if (!isValid) {
alert("Please fill all required fields");
return;
}
stepsStore.set((current) => current + 1);
}Conclusion
You've built a production-ready multi-step form with:
- ✅ Proper data persistence using Simple Store
- ✅ Form validation with Zod
- ✅ Clean navigation between steps
- ✅ Type-safe code with TypeScript
- ✅ Reactive UI that updates automatically
The key takeaway: Use useStoreValue + useEffect for reactive data persistence in multi-step forms!
Resources
Happy coding! 🚀
Questions or stuck? Drop a comment below or reach out on the JB Web Developer community!

