Next.js Multi-step Forms
Multi-step forms provide a better user experience by breaking complex forms into digestible sections. This approach reduces cognitive load, increases completion rates, and improves the overall user interface. In this tutorial, we'll learn how to implement multi-step forms in Next.js applications.
Introduction to Multi-step Forms
Multi-step forms (also called wizard forms) divide a lengthy form into multiple pages or steps. Instead of overwhelming users with dozens of fields on one page, these forms present information in logical, manageable chunks.
Benefits include:
- Improved user experience - less overwhelming for users
- Higher completion rates - users are more likely to finish the form
- Better organization - logically group related fields together
- Progressive data collection - gather important information first
Prerequisites
Before we start building, make sure you have:
- Basic knowledge of React and Next.js
- A Next.js project set up (Next.js 13+ with App Router is recommended)
- Understanding of React state management
Building a Basic Multi-step Form
Let's create a simple registration form with three steps:
- Account information (email, password)
- Personal details (name, date of birth)
- Preferences (notifications, themes)
Step 1: Create the Form Component Structure
First, let's set up our main form component:
'use client';
import { useState } from 'react';
import AccountForm from './AccountForm';
import PersonalDetailsForm from './PersonalDetailsForm';
import PreferencesForm from './PreferencesForm';
import FormSummary from './FormSummary';
export default function MultiStepForm() {
const [currentStep, setCurrentStep] = useState(1);
const [formData, setFormData] = useState({
email: '',
password: '',
firstName: '',
lastName: '',
dateOfBirth: '',
receiveNotifications: false,
theme: 'light',
});
const [isSubmitted, setIsSubmitted] = useState(false);
const updateFormData = (newData) => {
setFormData({ ...formData, ...newData });
};
const nextStep = () => {
setCurrentStep(currentStep + 1);
};
const prevStep = () => {
setCurrentStep(currentStep - 1);
};
const handleSubmit = () => {
// Submit data to your API
console.log('Form submitted:', formData);
setIsSubmitted(true);
};
return (
<div className="max-w-md mx-auto p-6 bg-white rounded-lg shadow-md">
<div className="mb-8">
<h1 className="text-2xl font-bold mb-2">Create Your Account</h1>
<div className="flex justify-between items-center">
{[1, 2, 3, 4].map((step) => (
<div
key={step}
className={`w-8 h-8 rounded-full flex items-center justify-center
${currentStep >= step ? 'bg-blue-500 text-white' : 'bg-gray-200'}`}
>
{step < 4 ? step : '✓'}
</div>
))}
</div>
</div>
{!isSubmitted ? (
<>
{currentStep === 1 && (
<AccountForm
formData={formData}
updateFormData={updateFormData}
nextStep={nextStep}
/>
)}
{currentStep === 2 && (
<PersonalDetailsForm
formData={formData}
updateFormData={updateFormData}
nextStep={nextStep}
prevStep={prevStep}
/>
)}
{currentStep === 3 && (
<PreferencesForm
formData={formData}
updateFormData={updateFormData}
nextStep={nextStep}
prevStep={prevStep}
/>
)}
{currentStep === 4 && (
<FormSummary
formData={formData}
prevStep={prevStep}
handleSubmit={handleSubmit}
/>
)}
</>
) : (
<div className="text-center">
<h2 className="text-xl font-bold text-green-600">Registration Complete!</h2>
<p className="mt-2">Thank you for creating your account.</p>
</div>
)}
</div>
);
}
Step 2: Create Individual Step Components
Now let's create each of the step components. Here's an example of the first step:
// AccountForm.js
import { useState } from 'react';
export default function AccountForm({ formData, updateFormData, nextStep }) {
const [errors, setErrors] = useState({});
const validate = () => {
const newErrors = {};
if (!formData.email) {
newErrors.email = 'Email is required';
} else if (!/\S+@\S+\.\S+/.test(formData.email)) {
newErrors.email = 'Email is invalid';
}
if (!formData.password) {
newErrors.password = 'Password is required';
} else if (formData.password.length < 6) {
newErrors.password = 'Password must be at least 6 characters';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = (e) => {
e.preventDefault();
if (validate()) {
nextStep();
}
};
return (
<form onSubmit={handleSubmit}>
<div className="mb-4">
<label className="block text-gray-700 mb-1">Email</label>
<input
type="email"
value={formData.email}
onChange={(e) => updateFormData({ email: e.target.value })}
className="w-full p-2 border rounded"
/>
{errors.email && <p className="text-red-500 text-sm mt-1">{errors.email}</p>}
</div>
<div className="mb-6">
<label className="block text-gray-700 mb-1">Password</label>
<input
type="password"
value={formData.password}
onChange={(e) => updateFormData({ password: e.target.value })}
className="w-full p-2 border rounded"
/>
{errors.password && <p className="text-red-500 text-sm mt-1">{errors.password}</p>}
</div>
<div className="flex justify-end">
<button
type="submit"
className="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded"
>
Next
</button>
</div>
</form>
);
}
Create similar components for the other steps (PersonalDetailsForm.js
, PreferencesForm.js
, and FormSummary.js
). Each form will validate its own fields and either move forward or show validation errors.
Managing Form State
For our example above, we used React's useState
to manage form state within the parent component. For more complex forms, consider these alternatives:
Using React Context
For larger forms, React Context can help avoid prop drilling:
// FormContext.js
import { createContext, useContext, useState } from 'react';
const FormContext = createContext();
export function FormProvider({ children }) {
const [formData, setFormData] = useState({
// Initial form values
});
const updateFormData = (newData) => {
setFormData(prev => ({ ...prev, ...newData }));
};
return (
<FormContext.Provider value={{ formData, updateFormData }}>
{children}
</FormContext.Provider>
);
}
export const useFormContext = () => useContext(FormContext);
Using Form Libraries
For more complex validation and state management, consider using form libraries:
With React Hook Form
import { useForm, FormProvider } from 'react-hook-form';
function StepOne({ nextStep }) {
const methods = useForm({
defaultValues: {
email: '',
password: '',
}
});
const onSubmit = (data) => {
console.log(data);
nextStep();
};
return (
<FormProvider {...methods}>
<form onSubmit={methods.handleSubmit(onSubmit)}>
{/* Form fields */}
</form>
</FormProvider>
);
}
Adding Animations for Better UX
Smooth transitions between steps enhance the user experience. Here's how to add animations using Framer Motion:
import { motion } from 'framer-motion';
// Inside your step component
return (
<motion.div
initial={{ opacity: 0, x: 100 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -100 }}
transition={{ duration: 0.3 }}
>
{/* Step content */}
</motion.div>
);
Persisting Form State
Users might accidentally refresh the page or want to continue later. Let's add state persistence with localStorage
:
useEffect(() => {
// Load saved data when component mounts
const savedData = localStorage.getItem('multistep-form');
if (savedData) {
setFormData(JSON.parse(savedData));
// Optionally restore step
const savedStep = localStorage.getItem('multistep-form-step');
if (savedStep) setCurrentStep(parseInt(savedStep));
}
}, []);
useEffect(() => {
// Save data when it changes
localStorage.setItem('multistep-form', JSON.stringify(formData));
localStorage.setItem('multistep-form-step', currentStep.toString());
}, [formData, currentStep]);
Real-world Example: Checkout Process
Let's implement a practical e-commerce checkout form with four steps:
- Shipping information
- Billing information (with option to use shipping address)
- Payment details
- Order review
// CheckoutForm.js
'use client';
import { useState } from 'react';
import ShippingForm from './steps/ShippingForm';
import BillingForm from './steps/BillingForm';
import PaymentForm from './steps/PaymentForm';
import ReviewOrder from './steps/ReviewOrder';
export default function CheckoutForm() {
const [step, setStep] = useState(1);
const [formData, setFormData] = useState({
// Shipping info
shippingName: '',
shippingAddress: '',
shippingCity: '',
shippingZip: '',
shippingCountry: '',
// Billing info (initially empty)
sameAsShipping: true,
billingName: '',
billingAddress: '',
billingCity: '',
billingZip: '',
billingCountry: '',
// Payment info
cardName: '',
cardNumber: '',
expiryDate: '',
cvv: '',
});
const updateData = (data) => {
setFormData(prev => {
const newData = { ...prev, ...data };
// If "same as shipping" is checked, copy shipping to billing
if (data.sameAsShipping === true) {
return {
...newData,
billingName: newData.shippingName,
billingAddress: newData.shippingAddress,
billingCity: newData.shippingCity,
billingZip: newData.shippingZip,
billingCountry: newData.shippingCountry,
};
}
return newData;
});
};
const submitOrder = async () => {
// In a real app, you would send this to your API
try {
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 1500));
alert('Order placed successfully!');
// Reset form or redirect
} catch (error) {
alert('Error placing order');
}
};
const nextStep = () => setStep(prev => prev + 1);
const prevStep = () => setStep(prev => prev - 1);
const renderStep = () => {
switch (step) {
case 1:
return <ShippingForm formData={formData} updateData={updateData} nextStep={nextStep} />;
case 2:
return <BillingForm formData={formData} updateData={updateData} nextStep={nextStep} prevStep={prevStep} />;
case 3:
return <PaymentForm formData={formData} updateData={updateData} nextStep={nextStep} prevStep={prevStep} />;
case 4:
return <ReviewOrder formData={formData} submitOrder={submitOrder} prevStep={prevStep} />;
default:
return null;
}
};
return (
<div className="max-w-2xl mx-auto p-6 bg-white rounded-lg shadow">
<div className="mb-6">
<h1 className="text-2xl font-bold">Complete Your Purchase</h1>
<div className="mt-4 flex items-center">
{['Shipping', 'Billing', 'Payment', 'Review'].map((label, i) => (
<div key={i} className="flex-1 flex items-center">
<div className={`w-8 h-8 rounded-full flex items-center justify-center
${step > i + 1 ? 'bg-green-500' : step === i + 1 ? 'bg-blue-500' : 'bg-gray-200'}
text-white text-sm`}>
{step > i + 1 ? '✓' : i + 1}
</div>
<div className={`ml-2 text-sm ${step === i + 1 ? 'font-bold' : ''}`}>
{label}
</div>
{i < 3 && <div className="flex-1 h-1 mx-2 bg-gray-200"></div>}
</div>
))}
</div>
</div>
{renderStep()}
</div>
);
}
Best Practices for Multi-step Forms
- Show progress indicators: Always let users know where they are in the process
- Allow navigation between steps: Let users go back to review or change previous inputs
- Validate incrementally: Validate each step before proceeding but collect all data at the end
- Save data between steps: Prevent data loss if users refresh the page
- Provide clear instructions: Tell users what to expect at each step
- Optimize for mobile: Ensure your form works well on small screens
- Test with real users: Multi-step forms should be thoroughly tested for usability
Summary
Multi-step forms provide a way to break complex forms into manageable chunks, enhancing user experience and increasing form completion rates. We've learned how to:
- Structure multi-step forms in Next.js
- Manage form state between steps
- Validate data at each step
- Enhance UX with animations and progress indicators
- Persist form data to prevent data loss
- Implement a real-world checkout process
By applying these techniques, you can create multi-step forms that are both user-friendly and effective at collecting the information you need.
Additional Resources
- React Hook Form Documentation
- Framer Motion for animations
- Next.js Documentation
- Web.dev Form Best Practices
Exercises
- Extend the checkout example to include form validation for each step
- Add a stepper component that allows users to jump to previously completed steps
- Implement a "save for later" feature that emails a unique link to continue the form
- Create a multi-step form that works without JavaScript (progressive enhancement)
- Add accessibility features to ensure the form is usable by everyone
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)