Stop Duplicating Validation Logic in Next.js with Zod
Source: Dev.to
Nowadays, we’re more than just frontend developers
With Next.js we build both client and server code in the same project, creating full‑stack applications without switching contexts. That also means we’re responsible for validations in more than one place.
- We validate on the client to improve user experience.
- We validate again on the server to ensure security.
The problem is that this usually leads to duplicated logic: the same rules are written twice—once for the form and once for the API. When a rule changes, you have to remember to update it everywhere, creating an unnecessary sync headache.
What if a single schema could handle validation on both the client and the server?
Enter Zod. A Zod‑first approach makes this possible: one source of truth, validated everywhere. Let’s see how it works.
Scenario
Think about a typical signup form. You need three fields: name, email, and website. Your first step is probably creating a TypeScript interface:
// lib/types/user.ts
export interface SignupInput {
name: string;
email: string;
website: string;
}
That looks clean in your editor, but interfaces don’t validate anything at runtime—they disappear when your code compiles to JavaScript. So you add HTML5 validation (type="email" and required attributes). That helps users, but it isn’t secure; anyone can open DevTools, change the input type, and send invalid data to your server.
To actually protect your app, you write manual validation:
export function validateSignup(input: SignupInput) {
const errors: Partial> = {};
if (!input.name || input.name.length ;
What just happened?
- We defined validation rules using Zod’s simple API—no RegEx needed.
z.infercreates a TypeScript type from the schema, soSignupInputalways matches the validation rules.
Now we have a schema that validates data and generates TypeScript types. Next, we’ll use it on the server.
4. Validate on the server
In Next.js we can use Server Actions (or an API route) to handle form submissions. This is where validation is crucial—we can’t trust data coming from the client.
Create app/actions/signup.ts:
"use server";
import { signupSchema, SignupInput } from "@/lib/schemas/signup";
export async function registerUser(data: unknown) {
// safeParse returns an object instead of throwing
const result = signupSchema.safeParse(data);
if (!result.success) {
return {
success: false,
errors: result.error.flatten().fieldErrors,
};
}
const validData: SignupInput = result.data;
// Here you would save to your database
return {
success: true,
user: validData,
};
}
Why .safeParse() instead of .parse()?
.safeParse() never throws; it returns { success: boolean, data?: T, error?: ZodError }. This makes error handling in UI code straightforward.
Our server is now protected with a single source of truth.
(Optional) Hook it up with react‑hook‑form
You can later integrate the schema with react-hook-form using zodResolver to get client‑side validation for free. The same schema will be shared between client and server, eliminating duplication entirely.
Recap
- One schema (
signupSchema) defines validation rules once. - The schema works both at runtime (client & server) and at compile time (TypeScript types).
- Using
.safeParse()on the server gives us graceful error handling without throwing.
With Zod, you eliminate duplicated validation logic, reduce bugs, and keep your codebase clean and maintainable. Happy coding!
Invalid data gets rejected with clear error messages. Now let’s build the frontend form.
Building the Form with Plain React
Create a form using Zod for validation, but with plain React state management. This shows how Zod works independently—you don’t need any special form library.
File: app/_auth/signup-form.tsx
"use client";
import { useState, FormEvent } from "react";
import { signupSchema, SignupInput } from "@/lib/schemas/signup";
import { registerUser } from "@/app/actions/signup";
export function SignupForm() {
const [formData, setFormData] = useState({
name: "",
email: "",
website: "",
});
const [errors, setErrors] = useState>({});
const [isSubmitting, setIsSubmitting] = useState(false);
const handleChange = (e: React.ChangeEvent) => {
const { name, value } = e.target;
setFormData((prev) => ({
...prev,
[name]: value,
}));
// Clear error when user starts typing
if (errors[name]) {
setErrors((prev) => {
const newErrors = { ...prev };
delete newErrors[name];
return newErrors;
});
}
};
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
setErrors({});
// ✅ Validate with Zod (same schema as server!)
const result = signupSchema.safeParse(formData);
if (!result.success) {
// Convert Zod errors to a simple object
const fieldErrors: Record = {};
result.error.errors.forEach((error) => {
if (error.path[0]) {
fieldErrors[error.path[0].toString()] = error.message;
}
});
setErrors(fieldErrors);
setIsSubmitting(false);
return;
}
// Data is valid, send to server
const serverResult = await registerUser(result.data);
if (!serverResult.success) {
// Handle server‑side validation errors
setErrors(serverResult.errors || {});
setIsSubmitting(false);
return;
}
console.log("User registered successfully!", serverResult.user);
// Reset form
setFormData({
name: "",
email: "",
website: "",
});
setIsSubmitting(false);
};
return (
<form onSubmit={handleSubmit}>
<h2>Sign Up</h2>
{/* Name */}
<label>
Name
<input
name="name"
value={formData.name}
onChange={handleChange}
/>
</label>
{errors.name && <p>{errors.name}</p>}
{/* Email */}
<label>
Email
<input
name="email"
value={formData.email}
onChange={handleChange}
/>
</label>
{errors.email && <p>{errors.email}</p>}
{/* Website */}
<label>
Website
<input
name="website"
value={formData.website}
onChange={handleChange}
/>
</label>
{errors.website && <p>{errors.website}</p>}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Signing up..." : "Sign Up"}
</button>
</form>
);
}
We now have a working form that uses the same Zod schema on both the client and the server. It validates the data correctly and shows clear error messages.
However, writing all this boilerplate for state handling and error display can become tedious. Let’s simplify it.
Moving to React Hook Form
React Hook Form is a library that handles the “boring” parts of a form: tracking input values, showing errors, and managing submit state. Instead of writing many useState hooks, we let the library do the work.
To connect it with Zod, we use a resolver—a bridge that feeds the Zod schema into React Hook Form so it knows when the data is valid and which error messages to display. This eliminates the need to manually validate on every change.
Read more about React Hook Form here.
Install the dependencies
npm install react-hook-form @hookform/resolvers
Using Zod with React Hook Form
- Import the resolver from
@hookform/resolvers/zod. - Pass the Zod schema to
useFormvia the resolver. - Register inputs with
registerand let the library handle value changes and error tracking.
The next section (not shown here) will demonstrate the concise implementation using React Hook Form.
React Hook Form Integration
When using React Hook Form, we only need to connect our schema and our inputs.
We pass our signupSchema into the resolver option so the library uses our Zod rules.
Instead of many useState hooks, we use the register function to handle values and events automatically. This makes the code much smaller and cleaner.
- The
errorsobject now shows Zod messages automatically. - The form will only submit if all data is correct.
Update app/_auth/signup-form.tsx
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { signupSchema, SignupInput } from "@/lib/schemas/signup";
import { registerUser } from "@/app/actions/signup";
export function SignupForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
reset,
} = useForm({
resolver: zodResolver(signupSchema), // ✅ Same schema as server!
});
const onSubmit = async (data: SignupInput) => {
const result = await registerUser(data);
if (!result.success) {
console.error("Validation errors:", result.errors);
return;
}
console.log("User registered successfully!", result.user);
reset();
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<h2>Sign Up</h2>
{/* Name */}
<label>
Name
<input {...register("name")} />
</label>
{errors.name && <p>{errors.name.message}</p>}
{/* Email */}
<label>
Email
<input {...register("email")} />
</label>
{errors.email && <p>{errors.email.message}</p>}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Signing up..." : "Sign Up"}
</button>
</form>
);
}
Recap
- We created a single Zod schema in
lib/schemas/signup.tsand used it for both the frontend and the backend. This means we write our validation rules only once. Changing a rule updates everywhere automatically. - React Hook Form makes our code cleaner by handling form state for us, while the
zodResolverconnects it to our schema. - Zod also generates TypeScript types, so we don’t have to write them manually.
Key takeaway: Stop doing the same work twice. By using Zod, your code becomes safer, easier to manage, and less error‑prone.
Stop repeating your validation logic. Start using Zod! 🚀