Streamlining Form Handling in Next.js with React Hook Form and TypeScript
Building a type-safe registration form in Next.js with React Hook Form and a Zod schema — setup, validation, and wiring the component into a page, step by step.

Forms are an integral part of modern web applications, and efficiently managing their state and validation can often be challenging. This is where React Hook Form shines. In this article, we’ll explore how to integrate React Hook Form into a Next.js project using TypeScript for enhanced type safety and developer experience.
Why React Hook Form?
React Hook Form (RHF) simplifies form handling in React applications by providing:
- Minimal re-renders, ensuring high performance.
- Simple API with hooks like useForm.
- Built-in validation support with libraries like Yup or Zod.
- Excellent TypeScript support for robust type definitions.
When paired with Next.js, RHF makes building forms seamless, even in complex, server-rendered applications.
Setting Up the Project
First, create a new Next.js project with TypeScript support:
npx create-next-app@latest my-nextjs-app --typescript
cd my-nextjs-appNext, install the required dependencies:
npm install react-hook-form @hookform/resolvers zod- react-hook-form: Core library for form handling.
- @hookform/resolvers: Provides schema validation support for libraries like Zod.
- zod: TypeScript-first schema validation library.
Creating a Form with React Hook Form
Let’s build a simple registration form with the following fields:
- Name (required, string)
- Email (required, email format)
- Password (required, minimum 6 characters)
- Repeat Password (required, must match Password)
Step 1: Define the Schema
Using Zod, define the validation schema:
import { z } from 'zod';
export const registrationSchema = z.object({
name: z.string().nonempty('Name is required'),
email: z.string().email('Invalid email address'),
password: z.string().min(6, 'Password must be at least 6 characters long'),
repeatPassword: z.string().min(6, 'Repeat Password must be at least 6 characters long'),
}).refine((data) => data.password === data.repeatPassword, {
message: 'Passwords do not match',
path: ['repeatPassword'],
});
export type RegistrationFormValues = z.infer<typeof registrationSchema>;Step 2: Build the Component
Create a RegistrationForm component:
import { useForm, SubmitHandler } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { registrationSchema, RegistrationFormValues } from "./schemas/registrationSchema";
const RegistrationForm = () => {
const form = useForm<RegistrationFormValues>({
resolver: zodResolver(registrationSchema),
});
const errors = form.formState.errors;
const onSubmit: SubmitHandler<RegistrationFormValues> = (data) => {
console.log(data);
};
return (
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
Name
</label>
<input
id="name"
type="text"
{...form.register("name")}
className={`mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm ${
errors.name ? "border-red-500" : ""
}`}
/>
{errors.name && <p className="mt-1 text-sm text-red-500">{errors.name.message}</p>}
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
Email
</label>
<input
id="email"
type="email"
{...form.register("email")}
className={`mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm ${
errors.email ? "border-red-500" : ""
}`}
/>
{errors.email && <p className="mt-1 text-sm text-red-500">{errors.email.message}</p>}
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
Password
</label>
<input
id="password"
type="password"
{...form.register("password")}
className={`mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm ${
errors.password ? "border-red-500" : ""
}`}
/>
{errors.password && <p className="mt-1 text-sm text-red-500">{errors.password.message}</p>}
</div>
<div>
<label htmlFor="repeatPassword" className="block text-sm font-medium text-gray-700">
Repeat Password
</label>
<input
id="repeatPassword"
type="password"
{...form.register("repeatPassword")}
className={`mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm ${
errors.repeatPassword ? "border-red-500" : ""
}`}
/>
{errors.repeatPassword && (
<p className="mt-1 text-sm text-red-500">{errors.repeatPassword.message}</p>
)}
</div>
<button
type="submit"
className="w-full rounded-md bg-indigo-600 py-2 px-4 text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
>
Register
</button>
</form>
);
};
export default RegistrationForm;Step 3: Integrate into a Page
Create a new page in app/register/page.tsx:
import RegistrationForm from '../../components/RegistrationForm';
const RegisterPage = () => {
return (
<div className="mx-auto max-w-md py-10">
<h1 className="text-2xl font-bold text-center mb-6">Register</h1>
<RegistrationForm />
</div>
);
};
export default RegisterPage;Testing the Form
Run the development server:
npm run devNavigate to http://localhost:3000/register to see the form in action. Try submitting the form with invalid inputs to see the validation errors.
Enhancements and Best Practices
- Styling: Use a library like Tailwind CSS or CSS Modules for styling your forms.
- Dynamic Validation: Adjust validation dynamically based on user interactions.
- API Integration: Send form data to a server using fetch or axios.
- Error Handling: Handle server-side errors gracefully by displaying feedback to users.
Conclusion
Integrating React Hook Form with Next.js and TypeScript provides a powerful combination for building robust and user-friendly forms. With minimal boilerplate, strong type safety, and excellent performance, RHF allows developers to focus on creating great user experiences.
Try it out in your next project and see the difference!


