Volver al inicio

Formularios en Next.js como un contrato: Zod y reglas unificadas

El artículo explica cómo tratar los formularios en Next.js no como componentes de UI, sino como contratos entre la interfaz, la validación y el servidor. Usar Zod, safeParse y flatten permite unificar reglas, formato de errores y estados, asegurando UX predecible y sin discrepancias entre cliente y servidor.

Cómo convertir formularios en Next.js en un contrato confiable usando Zod
Advertisement 728x90

Formularios en Next.js como un contrato: Zod, fieldErrors y reglas unificadas en cliente y servidor

Un formulario no es solo un conjunto de campos y botones: es un contrato entre la UI, la validación y la fuente de datos. Cuando este contrato no está definido explícitamente, surge el caos: la UI cree que los datos son válidos, el servidor lanza un error, los mensajes aparecen de forma aleatoria y el comportamiento del formulario es impredecible. ¿La solución? Unificar el esquema de validación, el formato de errores y los estados con Zod, safeParse y flatten para garantizar reglas idénticas tanto en el cliente como en el servidor.

Por qué los formularios fallan sin un contrato

Muchos proyectos comienzan de forma simple: entrada, estado, envío. Funciona bien para formularios pequeños. Pero a medida que crece la base de código, emergen tres puntos críticos de divergencia:

  • Reglas de validación inconsistentes. El cliente verifica la longitud de la cadena, pero el servidor también detecta palabras prohibidas o espacios. Los usuarios ven una marca verde de verificación, pero el envío falla.
  • Formatos de error no coincidentes. Una vez es una cadena, luego un objeto, después un array. La UI tiene que adivinar la estructura de la respuesta.
  • Estados no definidos. Pendiente, deshabilitado, éxito, formError: cada componente los maneja de forma diferente, lo que lleva a una UX fragmentada.

Sin un contrato único, cada formulario sigue sus propias reglas locales. No escala.

Google AdInline article slot

El formulario como un contrato: elementos clave

Un contrato de formulario debe responder claramente estas preguntas:

  • ¿Qué datos se consideran válidos?
  • ¿Qué errores corresponden a campos específicos (fieldErrors) y cuáles a la operación general (formError)?
  • ¿Cómo se maneja el estado pendiente?
  • ¿Qué se devuelve en caso de éxito?
  • ¿Puede el servidor devolver una respuesta en un formato diferente?
  • ¿Se usan las mismas reglas en cliente y servidor?

Estas respuestas deben estar definidas antes de implementar la UI. En Workbench, este enfoque estandarizó los formularios alrededor de esquemas Zod y una forma de respuesta consistente.

Un esquema Zod, dos usos

En lugar de duplicar la lógica de validación entre cliente y servidor, usa un único esquema Zod en todas partes. Ejemplo de esquema para una nota:

Google AdInline article slot
import { z } from "zod";

export const noteSchema = z.object({
  title: z
    .string()
    .trim()
    .min(3, "Enter minimum 3 simvola")
    .max(80, "Maximum 80 characters"),
  description: z
    .string()
    .trim()
    .max(500, "Maximum 500 characters")
    .optional()
    .or(z.literal("")),
}).refine(
  data => data.title.toLowerCase() !== "test",
  {
    path: ["title"],
    message: "Withlishkom tekhnicheskoe name for zametki",
  }
);

export type NoteInput = z.infer<typeof noteSchema>;

Este esquema define valores válidos, transformaciones (como trim) y reglas personalizadas (refine). Se convierte en la única fuente de verdad para la validación.

Validación segura con safeParse

Usar safeParse en lugar de parse es crucial para formularios. parse lanza una excepción en caso de error, lo cual es malo para la UX, donde se espera entrada inválida. safeParse devuelve un resultado manejable:

export function validateNote(input: unknown) {
  return noteSchema.safeParse(input);
}

El resultado incluye una bandera success y, si es necesario, un objeto error. Esto permite que la lógica de la UI y del servidor manejen el mismo tipo de respuesta sin try/catch.

Google AdInline article slot

Unificar errores con flatten

Zod devuelve un objeto de error complejo. Para formularios, bastan dos niveles: errores de campo y error general del formulario. El método flatten() es perfecto:

export function formatZodError(error: import("zod").ZodError) {
  const flat = error.flatten();

  return {
    fieldErrors: flat.fieldErrors,
    formError: flat.formErrors[0] ?? null,
  };
}

Diferencias de ejemplo:

  • fieldErrors: "El campo título no puede estar vacío", "La descripción excede los 500 caracteres".
  • formError: "Ya existe una nota con este slug", "Servidor temporalmente no disponible".

Esta separación hace que la UX sea predecible: los usuarios saben exactamente dónde corregir la entrada y cuándo reintentar o esperar.

Contrato unificado en cliente y servidor

En el cliente, el esquema Zod permite validación temprana y gestión de estados de la UI:

const clientResult = useMemo(() => {
  return noteSchema.pick({ title: true }).safeParse({ title });
}, [title]);

const titleError = clientResult.success
  ? null
  : clientResult.error.flatten().fieldErrors.title?.[0] ?? null;

const canSubmit = clientResult.success;

En el servidor, el mismo esquema actúa como la última línea de defensa:

export async function createNoteAction(raw: unknown) {
  const parsed = noteSchema.safeParse(raw);

  if (!parsed.success) {
    const flat = parsed.error.flatten();
    return {
      ok: false,
      fieldErrors: flat.fieldErrors,
      formError: flat.formErrors[0] ?? null,
    };
  }

  try {
    // save to database
    return {
      ok: true,
      fieldErrors: {},
      formError: null,
    };
  } catch {
    return {
      ok: false,
      fieldErrors: {},
      formError: "Not succeeded sokhranit zametku",
    };
  }
}

Una regla, roles diferentes: UX en cliente, seguridad en servidor.

Forma estándar de estado de formulario

Para evitar fragmentación, define un tipo único para todos los formularios:

export type FormState = {
  ok: boolean;
  fieldErrors: Record<string, string[] | undefined>;
  formError: string | null;
};

Esta forma cubre todos los estados: éxito, errores de campo, errores generales. Cada formulario en el proyecto devuelve la misma estructura, simplificando el manejo en la UI.

Cuándo usar React Hook Form

React Hook Form (RHF) es potente, pero no es una solución universal. Está justificado para formularios con:

  • Muchos campos con validación dependiente
  • Listas dinámicas o estructuras anidadas
  • Validación en blur, estados touched/dirty
  • Controles personalizados con gestión de foco
  • Necesidad de optimización de renderizado

Para formularios simples con 1–2 campos y validación básica, RHF añade complejidad innecesaria. Si useMemo + estados derivados de Zod lo resuelven, manténlo simple.

Cambio de mentalidad: del UI al contrato

¿El cambio clave? Deja de ver los formularios como componentes de UI. Un formulario es un contrato que define:

  • Esquema de datos válidos
  • Formato de respuesta de errores
  • Comportamiento para estados (pendiente, éxito, error)
  • Límites de responsabilidad entre cliente y servidor

Con el contrato definido de antemano, la implementación de la UI se vuelve mecánica. Lo más importante: cada formulario en el proyecto se comporta de forma predecible al seguir el mismo acuerdo.

Lecciones clave

  • Usa un esquema Zod para cliente y servidor: elimina divergencias de reglas.
  • Siempre usa safeParse: devuelve resultados manejables en lugar de excepciones.
  • Separa fieldErrors y formError: mejora la UX y simplifica la lógica de visualización.
  • Define una forma estándar de FormState: todos los formularios devuelven la misma estructura.
  • Añade React Hook Form solo cuando sea realmente necesario: no por tendencias, sino para escenarios complejos.

— Editorial Team

Advertisement 728x90

Leer después