Build a Multistep form in Next.js powered by React hook form and Zod

React hook form and Zod provides a powerful combination to build Multistep forms. React hook form manages the form data and Zod is used for schema validation.

· 5 min read
Mary Maina

Mary Maina

I am a frontend engineer passionate about building intuitive user interfaces. Tools: React, Next.JS, TypeScript, React Native, NodeJs

topics

Introduction

Multistep forms have become a common feature in modern web and mobile applications. When dealing with complex data inputs requiring user information, a multistep form can significantly enhance user engagement and improve data quality.

They provide an intuitive user experience since a user can categorically fill in the form data in a particular viewport.

In this guide, we will look into how to build a multistep form in Nextjs using React hook form and Zod.

Prerequisites

  1. Install Nextjs
npx create-next-app@latest

On installation, you will get the following prompts. We will use Tailwind and Typescript.

What is your project named? my-app
Would you like to use TypeScript? No / Yes
Would you like to use ESLint? No / Yes
Would you like to use Tailwind CSS? No / Yes
Would you like to use `src/` directory? No / Yes
Would you like to use App Router? (recommended) No / Yes
Would you like to customize the default import alias (@/*)? No / Yes
What import alias would you like configured? @/*

After the prompts, create-next-app will create a folder with your preferred project name and install the required dependencies

2. Install React hook form

npm install react-hook-form

3. Install Zod

npm install zod

Why use React hook form and Zod?

  1. React Hook Form: This is a flexible and lightweight library for managing forms in React applications. Its primary appeal lies in its performance and minimal re-renders. React Hook Form uses a hook-based approach, which aligns well with the functional components paradigm in React. It offers features like built-in validation, error handling, and integration with various UI libraries, making it a developer-friendly choice.
  2. Zod: This is a TypeScript-first schema validation library. Schema refers to any data type from a string to a complex object. Zod schemas can be used to enforce type safety, ensuring that the data conforms to specified formats and types.

Setting up Context API

We will use context API to manage our form data. The custom context will store the form data and a function to update it.

import { Dispatch, SetStateAction } from "react";
import { createContext, useContext, useState } from "react";

interface FormValuesType {
  formValues: {};
  updateFormValues: (x: any) => void;
  currentStep: number;
  setCurrentStep: Dispatch<SetStateAction<number>>;
}
interface Props {
  children: React.ReactNode;
}
const FormContext = createContext<FormValuesType | null>(null);

export const FormProvider = ({ children }: Props) => {
  const [formValues, setFormValues] = useState({});
  const [currentStep, setCurrentStep] = useState(1);

  const updateFormValues = (updatedData: any) => {
    setFormValues((prevData) => ({ ...prevData, ...updatedData }));
  };
  const values = {
    formValues,
    updateFormValues,
    currentStep,
    setCurrentStep,
  };
  return <FormContext.Provider value={values}>{children}</FormContext.Provider>;
};
export const useFormContext = () => {
  const context = useContext(FormContext);
  if (context === null) {
    throw new Error("context must be used within the context provider");
  }
  return context;
};

Remember to wrap the application with the form provider, this ensures every component in the tree has access to the data stored in the context store.

"use client";
import { Inter } from "next/font/google";
import "./globals.css";
import { FormProvider } from "@/context/useFormContext";

const inter = Inter({ subsets: ["latin"] });

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <FormProvider>
        <body className={`flex justify-center my-24 ${inter.className}`}>
          {children}
        </body>
      </FormProvider>
    </html>
  );
}

Dynamic Stepper component

The stepper component is used to indicate the progress and number of steps that are required to complete and submit the form data.

import React from "react";
import { useFormContext } from "@/context/useFormContext";

const steps = ["Personal info", "Address info", "Payment info"];

const Stepper = () => {
  const { currentStep } = useFormContext();
  return (
    <div className="flex justify-between h-auto items-center my-3">
      {steps.map((step, index) => (
        <div
          key={index}
          className={` stepper ${currentStep === index + 1 && "active"} ${
            index + 1 < currentStep && "complete"
          }`}
        >
          <div className="step">{index + 1}</div>
          <p className="text-gray-500 text-sm">{step}</p>
        </div>
      ))}
    </div>
  );
};

export default Stepper;

We need to define some custom styles for the stepper component in the global.css file

@layer components {
  .stepper{
    @apply flex relative flex-col justify-center items-center w-full
  }
  .stepper:not(:first-child):before{
    @apply bg-slate-200 absolute right-2/4 top-1/3 translate-y-2/4 w-full h-[3px] content-['']
  }
  .step{
    @apply rounded-full w-10 h-10 items-center flex justify-center z-10 relative bg-slate-700 text-white font-semibold
  }
  .complete .step{
    @apply bg-green-400
  }
  .active .step{
   @apply bg-sky-600
  }
 
}

The steps can have different colors to indicate their completion status or which one is currently active

Creating the forms

We will create three forms which are sections of the Multiform component.

The forms include a personal details form, a Contact form, and a Payment form. All the forms use the React hook form to handle the form submission and Zod for schema validation.

With zodResolve, we can integrate Zod schema validation into the React Hook Form workflow.

Summary

A summary of the form components logic:

  • Defining the schema using Zod ensures validation rules and custom error messages for validation errors have been set.
  • Passing the Zod schema to the useForm hook using the resolve ensures React uses the specified Zod schema for validation.
  • React hook form populates the errors object with validation errors based on the Zod schema. The errors are displayed below the form Input.
  • The useRouter hook is instantiated to access the Next.js router for navigation.
  • The useForm hook from React Hook Form is invoked to initialize the form with validation based on the schema.
  • Each input field is registered with React Hook Form using the register function, which enables data binding and form validation a.
  • The form submission is handled by the handleSubmit function which involves the onSubmit callback when the form is submitted.
  • The onSubmit callback updates the form values, increments the current step of the form, and navigates to the next step.

Personal details form (Initial form)

This schema will define the structure and validation rules for the personal details form.

import { z } from "zod";

export const PersonalSchema = z.object({
  first_name: z.string().min(1, { message: "First name is required" }),
  last_name: z.string().min(1, { message: "Last name is required" }),
  phone: z.string().min(1, { message: "Phone number is required" }),
});

The personal details component

"use client";
import React from "react";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { useRouter } from "next/navigation";
import { zodResolver } from "@hookform/resolvers/zod";

import { PersonalSchema } from "../../schemas/personal-schema";

import FormLayout from "./form-layout";
import Label from "./form-label";
import Input from "./form-input";
import Error from "./input-error";
import Button from "./button";

const MainForm = () => {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<z.infer<typeof PersonalSchema>>({
    resolver: zodResolver(PersonalSchema),
  });
  const router = useRouter();
  const { first_name, last_name, phone } = errors;
  
  const onSubmit = (values: z.infer<typeof PersonalSchema>) => {
    router.push("/address");
  };
  return (
    <FormLayout>
      <form className="space-y-3" onSubmit={handleSubmit(onSubmit)}>
        <Label className="text-base">Personal information</Label>
        <div>
          <Label htmlFor="firstname">First name</Label>
          <Input {...register("first_name")} id="first_name" />
          {first_name && <Error error={first_name.message} />}
        </div>
        <div>
          <Label htmlFor="lastname">Last name</Label>
          <Input {...register("last_name")} id="last_name" />
          {last_name && <Error error={last_name.message} />}
        </div>
        <div>
          <Label htmlFor="phone">Phone number</Label>
          <Input {...register("phone")} id="phone" />
          {phone && <Error error={phone.message} />}
        </div>
        <Button type="submit">Next</Button>
      </form>
    </FormLayout>
  );
};

export default MainForm;

An example of the personal details form validation.

Image

Address form

The addressSchema set of rules defines the estate as an optional value.

import z from "zod";
export const addressSchema = z.object({
  address_line_1: z.string().min(1, { message: "Address line 1 is required" }),
  city: z.string().min(1, { message: "Address line 2 is required" }),
  address_line_2: z.string().min(1, { message: "Address line 2 is required" }),
  estate: z.string().optional(),
});

The sample code for the Address form component.

"use client";
import React from "react";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useRouter } from "next/navigation";

import { addressSchema } from "@/schemas/address-schema";
import { useFormContext } from "@/context/useFormContext";

import FormLayout from "../components/form-layout";
import Label from "../components/form-label";
import Button from "../components/button";

const Contact = () => {
  const router = useRouter();
  const { setCurrentStep, updateFormValues } = useFormContext();

  const { register, handleSubmit } = useForm<z.infer<typeof addressSchema>>({
    resolver: zodResolver(addressSchema),
  });
  const onSubmit = (values: z.infer<typeof addressSchema>) => {
    updateFormValues(values);
    setCurrentStep((prev) => prev + 1);
    router.push("/payment");
  };

  const handlePrevious = () => {
    router.push("/");
    setCurrentStep((prev) => prev - 1);
  };
  return (
    <FormLayout>
      <form className="space-y-3" onSubmit={handleSubmit(onSubmit)}>
        <div>
          <Label htmlFor="firstname">Address line 1</Label>
          <input {...register("address_line_1")} id="address_line_1" />
        </div>
        <div>
          <Label htmlFor="lastname">Address line 1</Label>
          <input {...register("address_line_2")} id="address_line_2" />
        </div>
        <div>
          <Label htmlFor="city">City</Label>
          <input {...register("city")} id="city" />
        </div>
        <div>
          <Label htmlFor="estate">Estate</Label>
          <input {...register("estate")} id="estate" />
        </div>
        <div className="flex justify-between h-auto items-center">
          <Button type="button" onClick={handlePrevious}>
            previous
          </Button>
          <Button type="submit">Next</Button>
        </div>
      </form>
    </FormLayout>
  );
};

export default Contact;

Based on this example, the estate is not a required value during form submission.

Image

The payment form

The payment form takes in the payment schema which defines the validation rules.

"use client";
import React from "react";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useRouter } from "next/navigation";

import { paymentSchema } from "@/schemas/payment-schema";
import { useFormContext } from "@/context/useFormContext";

import FormLayout from "../components/form-layout";
import Label from "../components/form-label";
import Error from "../components/input-error";

import Button from "../components/button";
const Contact = () => {
  const { setCurrentStep, updateFormValues } = useFormContext();
  const router = useRouter();

  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<z.infer<typeof paymentSchema>>({
    resolver: zodResolver(paymentSchema),
  });
  const { card_number, expiration_date, cvv } = errors;
  const onSubmit = (values: z.infer<typeof paymentSchema>) => {
    updateFormValues(values);
    setCurrentStep((prev) => prev + 1);
    router.push("/review");
  };

  const handlePrevious = () => {
    router.push("/address");
    setCurrentStep((prev) => prev - 1);
  };
  return (
    <FormLayout>
      <form className="space-y-3" onSubmit={handleSubmit(onSubmit)}>
        <div>
          <Label htmlFor="firstname">Credit card number</Label>
          <input {...register("card_number")} id="address_line_1" />
          {card_number && <Error error={card_number.message} />}
        </div>
        <div className="flex gap-2">
          <div>
            <Label htmlFor="lastname">Expiry date</Label>
            <input {...register("expiration_date")} id="address_line_2" />
            {expiration_date && <Error error={expiration_date.message} />}
          </div>
          <div>
            <Label htmlFor="lastname">Cvv</Label>
            <input {...register("cvv")} id="address_line_2" />
            {cvv && <Error error={cvv.message} />}
          </div>
        </div>

        <div className="flex justify-between h-auto items-center">
          <Button type="button" onClick={handlePrevious}>
            previous
          </Button>
          <Button type="submit">Next</Button>
        </div>
      </form>
    </FormLayout>
  );
};

export default Contact;
Image

The Review component

The Review component is used to display a review of form details, including the form values obtained from the useFormContext hook.

"use client";
import React from "react";
import { useFormContext } from "@/context/useFormContext";

import FormLayout from "../components/form-layout";
import Button from "../components/button";
import Label from "../components/form-label";

const Review = () => {
  const { formValues, currentStep } = useFormContext();
  return (
    <FormLayout>
      <Label>Form details</Label>
      <div className="w-full">
        <pre style={{ maxWidth: "100%", overflowX: "auto" }}>
          {JSON.stringify(formValues, null, 2)}
        </pre>
      </div>
      <Button className="text-center">Submit</Button>
    </FormLayout>
  );
};

export default Review;

Below are the complete form details once all the required form data has been provided.

Image

share

Mary Maina

I am a frontend devloper