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…
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.
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,npmor 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.
- Create a folder in the root of your project and name it
schema.mkdir schema - 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>;
- The first line imports a named import from the zod package.
- Next, we create a schema using
z.object({}) - Next, we start defining the shape of our form. Referencing the form above, we can see the fields match.
- 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:
- The first thing we do is to import the
useFormhook from React Hook Form, passing it a resolverzodResolverthat resolves Zod to be used by React Hook Form. We also pass a modemode:'onChange'which validates the fields and showing errors when there is a change, e.g, on each key type. We also specify our generic typeuseForm<ContactFormData>to make the hook aware of the data it’s dealing with. - Next, we create a loading state that will be used when we submit to show a loading indicator on our submit button.
- We also create a submit handler
onSubmitthat will receive our data from the form. React Hook Form will supply the data via thehandleSubmitfunction destructured from theuseFormhook. - 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 thehandleSubmitfunction. - 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> - 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.
- Create a folder called
actionsat the root of the projectmkdir actions - Create a file inside
actionsfolder calledindex.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:
- We first denote the file as a server component using
'use server' - We then import our
ContactFormData, contactSchemafrom our schema, which we created when we started this article. - We then create a form response type for our frontend
type FormResponse - We then create the
formSubmitHandlerfunction 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 theFormResponsetype. - We then use Zod’s
safeParse()function to validate the data submitted against the schema. - If the validation is not successful, we throw an error.
- 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.🥳🥳
- 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,
});
};
- We set
isLoading(true)to notify our button that starts loading. - We then call the
formSubmitHandlerand pass the data. - After the form handler responds we
setIsLoading(false)and we in turn cause the loading indicator to stop. - We also reset the form using the
reset()function destructured from theuseForm()hook. - 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:
메타데이터
- 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