🇺🇸 EN
Frontend
·11 min read·↗·🇺🇸 EN

Deja de duplicar la lógica de validación en Next.js con Zod

Portada principal del artículo: Deja de duplicar la lógica de validación en Next.js con Zod

Hoy en día somos más que desarrolladores frontend. Con Next.js escribimos código tanto para el cliente como para el servidor dentro del mismo proyecto, creando aplicaciones full-stack sin cambiar de contexto. Esto también significa que somos responsables de las validaciones en más de un lugar.

Validamos en el cliente para mejorar la experiencia de usuario y otra vez en el servidor para garantizar la seguridad. El problema es que esto suele provocar código duplicado: las mismas reglas escritas dos veces, una para el formulario y otra para la API. Cuando una regla cambia, tienes que acordarte de actualizarla en ambos sitios, lo que genera dolores de cabeza innecesarios para mantener todo sincronizado.

¿Qué pasaría si un solo esquema pudiera encargarse de la validación tanto en el cliente como en el servidor? Aquí es donde entra en juego Zod. Un enfoque centrado en Zod hace esto posible: una única fuente de la verdad, validada en todas partes. Veamos cómo funciona.

El escenario

Imagina un formulario de registro típico. Necesitas tres campos: nombre, correo electrónico y sitio web. Tu primer paso probablemente sea crear una interfaz de TypeScript:

// lib/types/user.ts
export interface SignupInput {
  name: string;
  email: string;
  website: string;
}

Esto se ve limpio en tu editor. Pero aquí está el truco: las interfaces no validan nada en tiempo de ejecución. Desaparecen cuando tu código se compila a JavaScript. Por eso sueles agregar validación con HTML5 (como los atributos type="email" y required). Esto ayuda a los usuarios, pero no es seguro. Cualquier persona puede abrir las DevTools, cambiar el tipo de input y enviar datos inválidos a tu servidor.

Para proteger tu aplicación de verdad, escribes validación manual:

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";
  }
  // Expresión regular manual para el email... ¡un dolor de cabeza!
  if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(input.email)) {
    errors.email = "Email format is not valid";
  }
  // Expresión regular manual para la URL... ¡otro dolor de cabeza!
  if (!/^https?:\/\/.+/.test(input.website)) {
    errors.website = "Website must be a valid URL";
  }
  return errors;
}

Esto funciona, pero también necesitamos proteger nuestra API contra datos inválidos y repetir exactamente la misma lógica en el servidor. Si cambio la longitud mínima del nombre, tengo que actualizarla en dos lugares distintos.

¿Por qué Zod?

Zod es una librería de validación de esquemas pensada para TypeScript. Imagina un esquema como un contrato para tus datos: define qué está permitido y qué no. Zod es potente porque podemos definir las reglas una sola vez y usar el mismo esquema en cualquier parte. Valida en tiempo de ejecución, funciona tanto en el cliente como en el servidor, y además obtenemos tipos de TypeScript gratis porque se generan directamente desde el esquema.

Se acabó escribir expresiones regulares a mano o sincronizar lógica de validación. Tenemos una sola fuente de la verdad.

Paso a paso: manos a la obra

Vamos a crear un formulario de registro funcional que valida tanto en el cliente como en el servidor usando el mismo esquema de Zod. Puedes seguir los pasos y ejecutarlo en tu propio proyecto de Next.js.

Para empezar rápido, puedes clonar este repositorio y tener una base lista. De esta manera no tienes que empezar desde cero: tendrás un proyecto de Next.js configurado y listo para usar.

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

Ahora agreguemos Zod y construyamos nuestro sistema de validación paso a paso.

Instalar Zod

Primero, instalamos Zod en nuestro proyecto:

npm install zod

Más adelante agregaremos react-hook-form para reducir código repetitivo, pero primero veamos cómo funciona Zod por sí solo.

Crear el esquema

Aquí es donde ocurre la magia. Definimos nuestras reglas de validación una sola vez, y Zod nos da tanto validación en tiempo de ejecución como tipos de TypeScript.

Crea el archivo lib/schemas/signup.ts:

import { z } from "zod";
 
// Este es nuestro esquema: la única fuente de la verdad
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"),
});
 
// Tipo de TypeScript inferido a partir del esquema
export type SignupInput = z.infer<typeof signupSchema>;

¿Qué acaba de pasar? Definimos reglas de validación usando la API simple de Zod. Sin necesidad de expresiones regulares. Zod se encarga de validar el email y la URL por nosotros.

La parte de z.infer es muy útil: crea un tipo de TypeScript a partir de nuestro esquema. De este modo, SignupInput tendrá la forma exacta que definimos con sus tipos correctos.

Ya tenemos un esquema que valida datos y genera tipos de TypeScript. A continuación, vamos a usarlo en el servidor.

Validar en el servidor

En Next.js usamos Server Actions para manejar los envíos de formularios. Aquí es donde más necesitamos validación: no podemos confiar en los datos que vienen del cliente.

Zod ofrece .safeParse(). Piensa en esto como la versión amigable de la validación. En lugar de lanzar errores y detener la ejecución, devuelve un objeto de resultado que podemos comprobar.

Crea el archivo 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;
 
  // Aquí guardarías los datos en tu base de datos
  return {
    success: true,
    user: validData,
  };
}

¿Por qué .safeParse() en lugar de .parse()? Las Server Actions funcionan mejor cuando no lanzamos excepciones. .safeParse() devuelve { success: boolean, data?: T, error?: ZodError }, lo cual es ideal para manejar las respuestas en la interfaz.

Nuestro servidor ahora está protegido. Los datos inválidos se rechazan con mensajes de error claros. Ahora construyamos el formulario en el frontend.

Construir el formulario con React puro

Vamos a crear un formulario usando Zod para la validación, pero con el manejo de estado estándar de React. Esto demuestra cómo funciona Zod de forma independiente: no necesitas ninguna librería especial de formularios.

Crea el archivo 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,
    }));
 
    // Limpiar el error cuando el usuario empieza a escribir
    if (errors[name]) {
      setErrors((prev) => {
        const newErrors = { ...prev };
        delete newErrors[name];
        return newErrors;
      });
    }
  };
 
  const handleSubmit = async (e: FormEvent) => {
    e.preventDefault();
    setIsSubmitting(true);
    setErrors({});
 
    // ✅ Validar con Zod (¡el mismo esquema que en el servidor!)
    const result = signupSchema.safeParse(formData);
 
    if (!result.success) {
      // Convertir los errores de Zod en un objeto simple
      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;
    }
 
    // Los datos son válidos, los enviamos al servidor
    const serverResult = await registerUser(result.data);
 
    if (!serverResult.success) {
      // Manejar los errores de validación del servidor
      setErrors(serverResult.errors || {});
      setIsSubmitting(false);
      return;
    }
 
    console.log("User registered successfully!", serverResult.user);
    // Reiniciar el formulario
    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>
  );
}

Ahora tenemos un formulario funcional que usa el mismo esquema de Zod en el cliente y en el servidor. Valida los datos correctamente y muestra mensajes de error claros.

Pero como puedes notar, estamos escribiendo bastante código para manejar el estado del formulario y mostrar los errores. ¿Por qué no hacerlo mucho más fácil y limpio?

Simplificando con React Hook Form

React Hook Form es una librería que se encarga de las partes más tediosas de un formulario: seguir lo que el usuario escribe, mostrar errores y manejar el estado de envío. En lugar de escribir múltiples hooks useState, dejamos que la librería haga el trabajo pesado.

Para conectarla con Zod usamos un resolver. Piensa en el resolver como un puente: toma nuestro esquema de Zod y le dice a React Hook Form exactamente cuándo los datos son válidos o qué mensaje de error mostrar. De esta manera, no tenemos que evaluar manualmente el esquema cada vez que el usuario escribe una letra.

Puedes leer más sobre React Hook Form aquí.

Instalemos las dependencias:

npm install react-hook-form @hookform/resolvers

Para usar Zod con React Hook Form, solo necesitamos hacer dos cosas:

Solo tenemos que conectar nuestro esquema y nuestros inputs. Pasamos signupSchema dentro de la opción resolver para indicarle a la librería que aplique nuestras reglas de Zod. En lugar de usar muchos hooks useState, usamos la función register para manejar los valores y eventos automáticamente. Esto hace que el código sea mucho más limpio y compacto. El objeto errors ahora mostrará nuestros mensajes de Zod de forma automática, y el formulario solo se enviará si todos los datos son correctos.

Actualiza el archivo app/_auth/signup-form.tsx con estos cambios:

"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), // ✅ ¡El mismo esquema que en el servidor!
  });
 
  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>
  );
}

Resumen

Creamos un único esquema de Zod en lib/schemas/signup.ts y lo usamos tanto en el frontend como en el backend. Esto significa que solo escribimos nuestras reglas de validación una vez. Si modificamos una regla, se actualiza en todas partes automáticamente.

También vimos que React Hook Form hace que nuestro código sea más limpio. Maneja el estado del formulario por nosotros y zodResolver lo conecta con nuestro esquema. Otra gran ventaja es que Zod genera tipos de TypeScript por nosotros, evitando que tengamos que escribirlos a mano.

La idea central es simple: deja de hacer el mismo trabajo dos veces. Al usar Zod, tu código es más seguro, más fácil de mantener y tiene menos errores.

¡Deja de repetir tu lógica de validación y empieza a usar Zod! 🚀

Foto por Olga Anoshkina en Unsplash

Parte de la serie Frontend

Modern frontend development — Angular, React, TypeScript, accessibility, and performance.

Ver serie completa

Preguntas Frecuentes

¿Cómo ayuda Zod a evitar duplicar la lógica de validación entre cliente y servidor en Next.js?

Zod te permite definir un único esquema como fuente de la verdad para la estructura de tus datos y sus reglas de validación. Dado que Zod funciona tanto en Node.js como en el navegador, puedes colocar el esquema en un módulo compartido e importarlo en tus componentes de formulario de React para dar feedback en el cliente y en tus rutas de API o Server Actions de Next.js para comprobaciones de seguridad en el servidor, eliminando la necesidad de mantener las mismas reglas en dos lugares distintos.

¿Cuál es la ventaja de generar tipos de TypeScript a partir de un esquema de Zod en lugar de escribir interfaces por separado?

Al inferir un tipo de TypeScript directamente desde un esquema de Zod con z.infer, el tipo y las reglas de validación se mantienen siempre sincronizados. Si escribes una interfaz por separado, es fácil olvidar actualizar una cuando la otra cambia, provocando una discrepancia entre lo que TypeScript considera válido y lo que realmente se comprueba en tiempo de ejecución. Con la inferencia de Zod, tienes una única definición que maneja tanto el tipado en tiempo de compilación como la validación en tiempo de ejecución.

¿Por qué confiar únicamente en los atributos de validación de HTML5 como required y type email no es suficiente para asegurar un formulario en Next.js?

Los atributos de validación de HTML5 son comportamientos exclusivos del navegador en el cliente que cualquier usuario puede desactivar o saltarse usando las herramientas de desarrollo, peticiones HTTP directas o scripts de automatización. Ofrecen una buena experiencia de usuario, pero no garantizan seguridad real. Es necesario validar en el servidor con una librería como Zod dentro de tu ruta de API o Server Action para asegurar que solo se procesen o almacenen los datos que cumplan con tus reglas.

Artículos Relacionados

Compartir este artículo

Si esta guía te resultó útil, compártela con tu equipo o con otros desarrolladores.


Software real. Lecciones reales.

Comparto las lecciones que aprendí por las malas, para que puedas evitarlas o estar preparado cuando ocurran.

User avatar
User avatar
User avatar
User avatar
+13K

Únete a 13,800+ desarrolladores y lectores.

Cero spam. Cancela tu suscripción en cualquier momento.