JB logo

Command Palette

Search for a command to run...

yOUTUBE
Blog
PreviousNext

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 init

When 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
  • userStore holds all form data from both steps
  • stepsStore tracks which step the user is currently on
  • createStore() 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:

  1. stepsStore updates → Component re-renders with new step
  2. useStoreValue triggers → Component receives latest store data
  3. useEffect runsform.reset() populates all fields
  4. 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

  1. Start the dev server:

    pnpm dev
    
  2. Open http://localhost:3000

  3. 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! ✅
  4. 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!