← Back to list

Handling Forms in Next.js with React Hook Form, Zod, and Server Actions.

Working with forms in modern React applications can be very frustrating if you’re not using the right tools. Luckily, React Hook Form…

TechWithTwin · 2025-07-09 20:06 · 0 claps · 6.5 min read
#nextjs #nextjs-tutorial #zod #react-hook-form #techwithtwin
Open on Medium ↗
Wiki topics: 🌐 · Web Development

Handling Forms in Next.js with React Hook Form, Zod, and Server Actions.

[embed]How to work with React Hook Form and Zod

Working with forms in modern React applications can be very frustrating if you’re not using the right tools. Luckily, React Hook Form simplifies form state management, Zod provides robust schema validation, and Next.js Server Actions make it easy to handle data submissions securely.

What we will use:

  • React Hook Form — Lightweight form state management. Please find out more about it *here.*
  • Zod — Schema-based validation library. Please find out more about it *here.*
  • @hookform/resolvers/zod — Integrates Zod with React Hook Form. Please find out more about it *here.*
  • Next.js — React framework for building SEO-friendly applications. Please find out more about it *here.*

Prerequisite

  • Have Node version 20+ installed on your machine.
  • A text editor — I prefer VS Code.
  • Clone this repository with the starter form ***here**. Check out the initial branch, which has the starter code. You can also fork if you like.*
$ git clone https://github.com/techwithtwin/forms-in-nextjs;
$ cd forms-in-nextjs;
$ pnpm install;
$ git checkout -b initial
$ pnpm dev

What’s in the code?

The project you just cloned contains NextJS code with a form we will use to add React Hook Form and Zod.

It uses a component library called ***Chakra UI. However, we will not focus on styling; ***we will focus on React Hook Form integration.

NB: I hope moving forward, you have switched to the initial branch. Also, you can follow along in the YouTube video attached. Let’s continue.

This is how the form looks:

Image of how the project looks like. It features a lizard in the background and a form in the foreground.

Image of how the project looks like. It features a lizard in the background and a form in the foreground.

Installing React Hook Form, Zod, and Hook Form Resolvers

Next, we will install all the dependencies we will require. Open your terminal and navigate to your project folder, and run the following:

$ pnpm install react-hook-form@7.60.0 zod@3.25.76 @hookform/resolvers@5.1.1

Note: you can also use yarn , npm or any other package manager of your own choice.

Setting up the Schema

Next, we will set up the Zod schema for our form. Zod uses schemas to denote the shape of our form.

We describe what our form should have and the validations to be added to each field. For example, if we have an email field in the form, we can use z.object({email: z.email()}) and add more validations such as minimum length.

Don’t worry if you don’t understand this; we will tackle it later. Follow the following steps.

  1. Create a folder in the root of your project and name it schema .mkdir schema
  2. Create a file inside the schema folder and call it index.ts . touch schema/index.ts

NB: Creating a schema folder is not mandatory we just want to organize all the schema logic.

Inside the schema/index.ts file add the following code:

import { z } from "zod";

//contacts schema - this should match the shape of the form.
export const contactSchema = z.object({
  name: z.string().min(2, "Too short").max(50, "Too long"),
  subject: z.string().min(5, "Too short").max(50, "Too long"),
  email: z.string().email("Invalid email"),
  message: z.string().min(10, "Too short").max(500, "Too long"),
});

// we create a type that infers from the contact schema to prevent duplicating
export type ContactFormData = z.infer<typeof contactSchema>;
  1. The first line imports a named import from the zod package.
  2. Next, we create a schema using z.object({})
  3. Next, we start defining the shape of our form. Referencing the form above, we can see the fields match.
  4. Finally, we infer the schema type from the schema to prevent us from redefining the data object. It’s a typescript thing, when we submit the data, Typescript will want to know what shape it is, so defining it here is a great deal; we will just import it.

Adding React Hook Form to our form.

Navigate to the contact form file located in the /components/contact-form.tsx and open it for editing.

// components/contact-form.tsx

"use client";
import { ContactFormData, contactSchema } from "@/schema";
import {
  Button,
  Field,
  Heading,
  Input,
  Stack,
  Textarea,
} from "@chakra-ui/react";
import { zodResolver } from "@hookform/resolvers/zod";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { IoIosSend } from "react-icons/io";

const ContactForm = () => {
  const {
    register,
    handleSubmit,
    reset,
    formState: { errors },
  } = useForm<ContactFormData>({
    resolver: zodResolver(contactSchema),
    mode: "onChange",
  });
  const [isLoading, setIsLoading] = useState(false);

  const onSubmit = async (data: ContactFormData) => {
    console.log(data);
  };
  return (
    <Stack
      bg="rgba(255, 255, 255, 0.75)"
      borderRadius="xl"
      boxShadow="md"
      w={{ base: "md", sm: "xl" }}
      backdropFilter="blur(5px)"
      p={6}
      mx="5%"
      as="form"
      onSubmit={handleSubmit(onSubmit)}
    >
      <Heading mb=".5rem" size="2xl" color="gray.800">
        Contact Us
      </Heading>

      <hr />

      <Field.Root required invalid={!!errors.name}>
        <Field.Label>
          Name <Field.RequiredIndicator />
        </Field.Label>
        <Input
          type="text"
          placeholder="John Doe"
          {...register("name")}
          variant="subtle"
        />
        <Field.ErrorText>{errors.name?.message}</Field.ErrorText>
      </Field.Root>
      <Field.Root required invalid={!!errors.email}>
        <Field.Label>
          Email <Field.RequiredIndicator />
        </Field.Label>
        <Input
          type="email"
          placeholder="john@example.com"
          {...register("email")}
          variant="subtle"
        />
        <Field.ErrorText>{errors.email?.message}</Field.ErrorText>
      </Field.Root>
      <Field.Root required invalid={!!errors.subject}>
        <Field.Label>
          Subject <Field.RequiredIndicator />
        </Field.Label>
        <Input
          type="text"
          placeholder="Inquiry Subject"
          variant="subtle"
          {...register("subject")}
        />
        <Field.ErrorText>{errors.subject?.message}</Field.ErrorText>
      </Field.Root>
      <Field.Root required invalid={!!errors.message}>
        <Field.Label>
          Message <Field.RequiredIndicator />
        </Field.Label>
        <Textarea
          placeholder="Your message here..."
          variant="subtle"
          {...register("message")}
        />
        <Field.ErrorText>{errors.message?.message}</Field.ErrorText>
      </Field.Root>
      <Button colorPalette="teal" mt=".5rem" type="submit" loading={isLoading}>
        Send <IoIosSend />
      </Button>
    </Stack>
  );
};

export default ContactForm;

File Explained:

  1. The first thing we do is to import the useForm hook from React Hook Form, passing it a resolver zodResolver that resolves Zod to be used by React Hook Form. We also pass a mode mode:'onChange' which validates the fields and showing errors when there is a change, e.g, on each key type. We also specify our generic type useForm<ContactFormData> to make the hook aware of the data it’s dealing with.
  2. Next, we create a loading state that will be used when we submit to show a loading indicator on our submit button.
  3. We also create a submit handler onSubmit that will receive our data from the form. React Hook Form will supply the data via the handleSubmit function destructured from the useForm hook.
  4. Next, we move on to mark our wrapper <Stack as='form' onSubmit={handleSubmit(onSubmit)}></Stack> as a form, and also allow React Hook Form to take over the form submission via the handleSubmit function.
  5. We move on to each field in our form and we add a new prop invalid={!!errors.name} to each field to allow React Hook Form to inject error states into the <Field invalid={!!errors.name}/> which notifies the field that there is an issue. We also add a <Field.ErrorText>{errors.name?.message}></Field.ErrorText> which displays the errors as soon as they are available. Make sure to add the error display inside the <Field.Root></Field.Root>
  6. We also register the inputs using {...register("name")} which adds some new props to the input. This is how it looks and what is destructured:

It adds new properties and taps into some methods to help manage the inputs.

Finally, when we console.log(data) on our submit handler, we can see the data, from the inputs and instant validations.

Submitting the data.

React Hook Form can also be used to verify the information submitted in the backend, and for this reason, we will use Next.js server actions to simulate that.

Before we submit our data, add this to your layout /app/layout.tsx to enable us to show toast notifications.

//import the Toaster component
import { Toaster } from "@/components/ui/toaster";

//In your provider add the Toaster component
<Provider>
   {children}
    <Toaster />
</Provider>

If you are not able to change, reference here

Follow the following steps to create our action. An action in Nextjs is an API route in our backend that handles form submissions.

  1. Create a folder called actions at the root of the project mkdir actions
  2. Create a file inside actions folder called index.ts . touch actions/index.ts

Add the following Content:

"use server";

import { ContactFormData, contactSchema } from "@/schema";

type FormResponse = {
  status: boolean;
  message: string;
};

export async function formSubmitHandler(
  data: ContactFormData,
): Promise<FormResponse> {
  try {
    let validation = contactSchema.safeParse(data);

    if (!validation.success) throw new Error("Invalid data");

    const { name, email, message, subject } = validation.data;
    //continue to use the data

    return {
      status: true,
      message: "Form submitted successfully",
    };
  } catch (error) {
    console.error("e", error);
    return {
      status: false,
      message: "Failed",
    };
  }
}

File Explained:

  1. We first denote the file as a server component using 'use server'
  2. We then import our ContactFormData, contactSchema from our schema, which we created when we started this article.
  3. We then create a form response type for our frontend type FormResponse
  4. We then create the formSubmitHandler function that receives the data and we type it to conform to typescript typings, and we specify that this function is asynchronous and it returns the data that conforms to the FormResponse type.
  5. We then use Zod’s safeParse() function to validate the data submitted against the schema.
  6. If the validation is not successful, we throw an error.
  7. If the validation is successful we extract our data from the validated data and we can continue to use it or save it in a database.🥳🥳
  8. Finally, if there is an error we catch it and return a response.

To submit the data, we need to modify our onSubmit function in our contact-form.tsx component located at components/contact-form.tsx

const onSubmit = async (data: ContactFormData) => {
    setIsLoading(true);
    const res = await formSubmitHandler(data);
    setIsLoading(false);
    reset();

    toaster.create({
      type: res.status ? "success" : "error",
      title: res.message,
    });
};
  1. We set isLoading(true) to notify our button that starts loading.
  2. We then call the formSubmitHandler and pass the data.
  3. After the form handler responds we setIsLoading(false) and we in turn cause the loading indicator to stop.
  4. We also reset the form using the reset() function destructured from the useForm() hook.
  5. Finally, we call the toaster function and show a toast notification.

NB: Make sure to import the toaster . import { toaster } from './ui/toaster'

Conclusion

And that’s it, guys, that’s how we add React Hook Form to a Next.js application.

Happy Coding!

Don’t forget to follow TechWithTwin for more content:

[embed]TechWithTwin | Top Software Consultant in Kenya Discover TechWithTwin, Kenya's leading software and digital marketing consultant. I specialize in web development, SEO…techwithtwin.com

[embed]TechWithTwin Techwithtwinyoutube.com

https://x.com/techwithtwin

[embed]TikTok - Make Your Day Edit descriptiontiktok.com


메타데이터
post_id
e148d4dc6dc1
slug
handling-forms-in-next-js-with-react-hook-form-zod-and-server-actions-e148d4dc6dc1
url
https://medium.com/@techwithtwin/handling-forms-in-next-js-with-react-hook-form-zod-and-server-actions-e148d4dc6dc1
canonical_url
https://medium.com/@techwithtwin/handling-forms-in-next-js-with-react-hook-form-zod-and-server-actions-e148d4dc6dc1
author_url
https://medium.com/@techwithtwin
status
ok
fetched_at
2026-08-23 11:44:37