Nextjs

Stop Duplicating Validation Logic in Next.js with Zod

Stop Duplicating Validation Logic in Next.js with Zod

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, and again on the server to ensure security. The problem is that this usually leads to duplicated logic: the same rules written twice, one for the form and one 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? This is when Zod comes into play. 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;
}

This looks clean in your editor. But here's the catch: 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's not 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<Record<keyof SignupInput, string>> = {};
 
  if (!input.name || input.name.length < 3) {
    errors.name = "Name must be at least 3 characters";
  }
  // Manual RegEx for email... what a pain!
  if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(input.email)) {
    errors.email = "Email format is not valid";
  }
  // Manual RegEx for URL... what a pain!
  if (!/^https?:\/\/.+/.test(input.website)) {
    errors.website = "Website must be a valid URL";
  }
  return errors;
}

Its works, but to we need also to protect our API from invalid data, and repeat the exact same logic on the server. If Change the minimum name length, then I need to update it in two places.

Why Zod?

Zod is a TypeScript-first schema validation library. Think of a schema as a contract for your data—it defines what's allowed and what's not. Zod powerful because we can define rules once and use the same schema everywhere, its validate at runtime and works on both client and server and get TypeScript types for free because our types are generated from your schema.

No more writing RegEx patterns or syncing validation logic. One source of truth.

Let's Build It Step by Step

We'll create a working signup form that validates on both the client and server using the same Zod schema. You can follow along and run this in your own Next.js project.

To get started quickly, you can clone this repository to have a base project ready. This way, you don't have to start from scratch—you'll have a Next.js project set up and ready to go.

git clone https://github.com/danywalls/improve-nextjs.git
cd improve-nextjs
npm install

Now let's add Zod and build our validation system step by step.

Install Zod

First, let's add Zod to our project:

npm install zod

Later we'll add react-hook-form later to reduce boilerplate, but first let's see how Zod works on its own.

Creating the Schema

This is where the magic happens. We define our validation rules once, and Zod gives us both runtime validation and TypeScript types.

Create lib/schemas/signup.ts:

import { z } from "zod";
 
// This is our schema—the single source of truth
export const signupSchema = z.object({
  name: z.string().min(3, "Name must be at least 3 characters"),
  email: z.string().email("Invalid email format"), 
  website: z.string().url("Website must be a valid URL"),
});
 
// TypeScript type inferred from the schema
export type SignupInput = z.infer<typeof signupSchema>;

What just happened? We defined validation rules using Zod's simple API. No RegEx needed. Zod handles email and URL validation for us.

The z.infer part is interesting—it creates a TypeScript type from our schema. So SignupInput will have the exact shape we defined, with proper types.

We now have a schema that validates data and generates TypeScript types. Next, we'll use it on the server.

Validating on the Server

In Next.js, we use Server Actions to handle form submissions. This is where we need validation the most—we can't trust data coming from the client.

Zod provides .safeParse(). Think of this as the "friendly" version of validation. Instead of throwing errors and crashing, it returns a result object we can check.

Create app/actions/signup.ts:

"use server";
import { signupSchema, SignupInput } from "@/lib/schemas/signup";
 
export async function registerUser(data: unknown) {
 
  const result = signupSchema.safeParse(data);
 
  if (!result.success) {
    return {
      success: false,
      errors: result.error.flatten().fieldErrors,
    };
  }
 
 
  const validData = result.data;
 
  // Here you would save to your database
  return {
    success: true,
    user: validData,
  };
}

Why .safeParse() instead of .parse()? Server Actions work better when we don't throw errors. .safeParse() returns { success: boolean, data?: T, error?: ZodError }, which is perfect for handling in our UI.

Our server is now protected. Invalid data gets rejected with clear error messages. Now let's build the frontend form.

Building the Form with Plain React

Let's 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.

Create 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<SignupInput>({
    name: "",
    email: "",
    website: "",
  });
 
  const [errors, setErrors] = useState<Record<string, string>>({});
  const [isSubmitting, setIsSubmitting] = useState(false);
 
  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    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<string, string> = {};
      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}
      className="flex flex-col gap-4 max-w-sm p-6 bg-white dark:bg-gray-800 rounded-lg shadow-md"
    >
      <h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
        Sign Up
      </h2>
 
      {/* Name */}
      <div>
        <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
          Name
        </label>
        <input
          name="name"
          value={formData.name}
          onChange={handleChange}
          placeholder="Your name"
          className="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
        />
        {errors.name && (
          <p className="text-red-500 text-xs mt-1">{errors.name}</p>
        )}
      </div>
 
      {/* Email */}
      <div>
        <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
          Email
        </label>
        <input
          name="email"
          type="email"
          value={formData.email}
          onChange={handleChange}
          placeholder="your@email.com"
          className="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
        />
        {errors.email && (
          <p className="text-red-500 text-xs mt-1">{errors.email}</p>
        )}
      </div>
 
      {/* Website */}
      <div>
        <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
          Website
        </label>
        <input
          name="website"
          type="url"
          value={formData.website}
          onChange={handleChange}
          placeholder="https://example.com"
          className="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
        />
        {errors.website && (
          <p className="text-red-500 text-xs mt-1">{errors.website}</p>
        )}
      </div>
 
      <button
        type="submit"
        disabled={isSubmitting}
        className="bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 text-white font-semibold py-2 px-4 rounded-md transition-colors mt-4"
      >
        {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 checks the data correctly and shows clear error messages.

But as you can see, we are writing a lot of code to manage the form and show errors, why not make this much easier and cleaner?

Moving to React Hook Form

React Hook Form is a library that handles all the "boring" parts of a form: tracking what the user types, showing errors, and managing the submit state. Instead of writing many useState hooks, we let the library do the work.

To connect it with Zod, we use a resolver. Think of the resolver as a bridge: it takes our Zod schema and tells React Hook Form exactly when the data is valid or what error message to show. This way, we don't have to manually check the schema every time a user types a letter.

Read more about React Hook Form here.

Let's install the dependencies:

npm install react-hook-form @hookform/resolvers

To use Zod with React Hook Form, we only need to do two things:

To use Zod with React Hook Form, we only need to connect our schema and our inputs. We pass our signupSchema into the resolver option to tell the library to use our Zod rules. Instead of using many useState hooks, we use the register function to handle values and events automatically. This makes the code much smaller and cleaner. The errors object will now show our Zod messages automatically, and the form will only submit if all data is correct.

Update the app/_auth/signup-form.tsx with the changes:

"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<SignupInput>({
    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)}
      className="flex flex-col gap-4 max-w-sm p-6 bg-white dark:bg-gray-800 rounded-lg shadow-md border border-neutral-200 dark:border-neutral-700"
    >
      <h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
        Sign Up
      </h2>
 
      {/* Name */}
      <div>
        <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
          Name
        </label>
        <input
          {...register("name")}
          placeholder="Your name"
          className="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500"
        />
        {errors.name && (
          <p className="text-red-500 text-xs mt-1">{errors.name.message}</p>
        )}
      </div>
 
      {/* Email */}
      <div>
        <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
          Email
        </label>
        <input
          {...register("email")}
          type="email"
          placeholder="your@email.com"
          className="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500"
        />
        {errors.email && (
          <p className="text-red-500 text-xs mt-1">{errors.email.message}</p>
        )}
      </div>
 
      <button
        type="submit"
        disabled={isSubmitting}
        className="bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 text-white font-semibold py-2 px-4 rounded-md transition-colors mt-4"
      >
        {isSubmitting ? "Signing up..." : "Sign Up"}
      </button>
    </form>
  );
}

Recap

We created a single Zod schema in lib/schemas/signup.ts and used it for both the frontend and the backend. This means we only write our validation rules once. If we change a rule, it updates everywhere automatically.

We also saw that React Hook Form makes our code cleaner. It handles the form state for us, and the zodResolver connects it to our schema. Another great thing is that Zod creates TypeScript types for us, so we don't have to write them by hand.

The main idea is simple: stop doing the same work twice. By using Zod, your code is safer, easier to manage, and has fewer errors.

Stop repeating your validation logic. Start using Zod! 🚀


Real Software. Real Lessons.

I share the lessons I learned the hard way, so you can either avoid them or be ready when they happen.

No spam ever. Unsubscribe at any time.

Discussion