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.
React Hook Form (RHF) simplifies form handling in React applications by providing:
When paired with Next.js, RHF makes building forms seamless, even in complex, server-rendered applications.
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 zodLet’s build a simple registration form with the following fields:
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>;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;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;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.
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!

A walkthrough of making a portfolio fully bilingual (EN/ID) — subpath routing with next-intl, per-field content localization in Payload CMS, and the 404 and SEO gotchas I hit along the way.

A Next.js app that renders QR codes as you type and lets you restyle every part — dots, corners, colors, an embedded logo — then export to PNG, JPEG, SVG, or WEBP.

A reusable AnimatedSection component that animates content in and out of the viewport based on scroll direction and position, built with Framer Motion and React hooks.