Módulo 11: Formularios y validación

Objetivos del módulo

  • Entender por qué la validación del servidor es obligatoria y la del navegador es solo una comodidad
  • Escribir esquemas con Zod 4 y conocer los cambios respecto a Zod 3 que todavía aparecen en tutoriales (y en la propia documentación de Next.js)
  • Convertir un FormData (donde todo es texto) en datos con su tipo correcto, sin caer en las trampas de z.coerce
  • Devolver desde una Server Action errores por campo y los valores que escribió el usuario, y pintarlos de forma accesible
  • Traducir los errores 422 de la API EMPRESA a errores por campo
  • Reutilizar el mismo esquema en el cliente para dar respuesta inmediata sin perder la seguridad del servidor
  • Comprobar de verdad, con JavaScript desactivado, que tus formularios funcionan antes de la hidratación, y saber por qué un <Suspense> puede romperlos
  • Subir ficheros con una Server Action: límite de tamaño, tipo real del contenido y dónde guardarlos
  • Construir formularios complejos: filas dinámicas, varios botones de envío, reglas entre campos y totales en vivo

💡 Consejo: Este módulo se apoya en el módulo 10, donde viste qué es una Server Action, 'use server', useActionState, useFormStatus, redirect y la revalidación. Aquí no lo repetimos: nos centramos en qué datos entran, cómo se validan y qué se le devuelve al usuario. Si useActionState no te suena, repasa también el módulo de Actions del Curso de React.

Todo el código de este módulo se ha probado sobre Next.js 16.4, React 19.3 y Zod 4.6 contra la API EMPRESA. Las salidas que verás (mensajes de error, códigos HTTP, cabeceras) son las reales, capturadas con Playwright con JavaScript activado y desactivado.


1. El problema: un formulario es una puerta abierta

Un formulario es la forma más directa que tiene un usuario de meter datos en tu sistema. Y una Server Action es, por dentro, un endpoint HTTP: cualquiera que sepa construir la petición puede llamarla, sin pasar por tu formulario, sin tus atributos required y sin tu JavaScript. Lo viste en el módulo 10: la acción recibe un FormData y hace lo que tú le digas con él.

Por eso en este módulo vamos a distinguir tres capas de validación:

Diagrama con tres columnas: navegador (atributos HTML y Zod en el cliente), Server Action (Zod en el servidor, la única obligatoria, y el estado que vuelve) y API EMPRESA (reglas de negocio como email repetido o stock, que devuelven 422)

Figura 1. Tres capas de validación. Las del navegador mejoran la experiencia; la del servidor protege los datos; la API decide lo que solo ella sabe.

Capa Dónde Funciona sin JS Se puede saltar Para qué sirve
Atributos HTML (required, type="email", min) Navegador Sí Sí, con DevTools o curl Avisos inmediatos y gratis
Zod en el cliente Navegador No Sí Mensajes propios, reglas complejas, sin ir a la red
Zod en el servidor Server Action No hace falta JS No Garantizar que lo que guardas es válido
Reglas de la API o la base de datos API EMPRESA No hace falta JS No Lo que depende de los datos guardados: unicidad, stock

La regla de oro es sencilla: valida siempre en el servidor, y añade el resto solo si mejora la experiencia. Si te sobra tiempo para una sola capa, que sea la del servidor.

📘 Concepto: “Validar” no es solo decir sí o no. Un buen validador también transforma: quita espacios, pasa el email a minúsculas, convierte "2450.50" en el número 2450.5 y "" en null. Lo que sale de la validación es lo que guardas; lo que entra, nunca.


2. Zod 4 en lo esencial

Zod es la biblioteca de validación más usada en el ecosistema de TypeScript. La idea es describir la forma de tus datos una vez, con un esquema, y obtener de él dos cosas: un validador que se ejecuta en tiempo de ejecución y un tipo de TypeScript que se comprueba al compilar.

npm install zod

La versión que usa este curso es Zod 4 (4.6.7 en el laboratorio). Se importa así:

import { z } from 'zod'

2.1 Un esquema, un tipo

import { z } from 'zod'

const esquemaContacto = z.object({
  nombre: z.string().trim().min(1, 'El nombre es obligatorio'),
  email: z.email({ error: 'El email no es válido' }),
  edad: z.number().int().min(16, 'Tienes que tener al menos 16 años'),
})

// El tipo se deduce del esquema: no lo escribes dos veces
type Contacto = z.infer<typeof esquemaContacto>
// { nombre: string; email: string; edad: number }

Para validar tienes dos métodos:

Método Si los datos son válidos Si no lo son
parse(datos) Devuelve los datos ya transformados Lanza un ZodError
safeParse(datos) { success: true, data } { success: false, error }

En una Server Action usaremos siempre safeParse: un dato no válido es una situación esperada (el usuario se ha equivocado), no una excepción. Las excepciones las reservamos para lo inesperado, que acabará en error.tsx (lo verás en el módulo 14).

2.2 Lo que cambia respecto a Zod 3

Zod 4 cambió varias cosas que todavía ves en tutoriales, en respuestas de foros y, lo comprobamos, en la propia guía de formularios de Next.js 16.4, que sigue usando la sintaxis de Zod 3:

// Ejemplo de la guía de Next.js (sintaxis de Zod 3)
const schema = z.object({
  email: z.string({
    invalid_type_error: 'Invalid Email',
  }),
})

Con Zod 4 instalado, TypeScript lo rechaza:

error TS2769: No overload matches this call.
  Object literal may only specify known properties, and 'invalid_type_error'
  does not exist in type '$ZodStringParams'.

Y si lo ejecutas igualmente (por ejemplo, en un fichero .js), el mensaje personalizado se ignora en silencio y sale el genérico: Invalid input: expected string, received number. Estos son los cambios que más te vas a encontrar:

Zod 3 Zod 4 Comentario
z.string({ invalid_type_error: '...', required_error: '...' }) z.string({ error: '...' }) Un solo parámetro error para todo
.min(1, { message: '...' }) .min(1, '...') o .min(1, { error: '...' }) message sigue funcionando, pero está obsoleto
z.string().email() z.email() Los formatos pasan a ser funciones de primer nivel
z.string().date() z.iso.date() Igual con datetime, time, duration
error.flatten() z.flattenError(error) El método sigue existiendo, pero obsoleto
error.format() z.treeifyError(error) Estructura en árbol, nueva
  z.prettifyError(error) Texto legible, para registros
  z.file(), z.stringbool() Nuevos y muy útiles en formularios

⚠️ Importante: Cuando copies un ejemplo de validación de cualquier sitio, mira primero si es de Zod 3. La pista más rápida es invalid_type_error, required_error o z.string().email(). En Zod 4 el código puede compilar a medias (los métodos obsoletos siguen ahí) y comportarse distinto de lo que esperas.

2.3 Los errores: issues y sus tres vistas

Cuando la validación falla, error.issues es la lista completa de problemas. Cada uno tiene path (dónde), code (qué tipo de fallo) y message (el texto). A partir de ahí Zod 4 ofrece tres funciones de ayuda. Este es el resultado real de validar un empleado mal relleno con el esquema del apartado 3:

=== issues ===
nombre [too_small] El nombre es obligatorio
email [invalid_format] El email no es válido
telefono [invalid_format] Nueve cifras, empezando por 6, 7, 8 o 9
fechaAlta [invalid_format] Fecha no válida
oficio [invalid_value] Elige un oficio de la lista
salario [invalid_type] Escribe un número

=== z.flattenError ===
{
  formErrors: [],
  fieldErrors: {
    nombre: [ 'El nombre es obligatorio' ],
    email: [ 'El email no es válido' ],
    telefono: [ 'Nueve cifras, empezando por 6, 7, 8 o 9' ],
    fechaAlta: [ 'Fecha no válida' ],
    oficio: [ 'Elige un oficio de la lista' ],
    salario: [ 'Escribe un número' ]
  }
}

=== z.treeifyError (solo nombre y telefono) ===
{"errors":[],"nombre":{"errors":["El nombre es obligatorio"]},"telefono":{"errors":["Nueve cifras, empezando por 6, 7, 8 o 9"]}}

=== z.prettifyError ===
✖ El nombre es obligatorio
  → at nombre
✖ El email no es válido
  → at email
...
Función Devuelve Úsala para
z.flattenError(e) { formErrors, fieldErrors } Formularios planos, un nivel de campos
z.treeifyError(e) Árbol { errors, properties, items } Objetos anidados y listas
z.prettifyError(e) Un texto Registros (console.log), nunca para el usuario

Para nuestros formularios usaremos una función propia, aún más simple, que funciona tanto con campos planos como anidados: convierte cada path en una clave con puntos. La verás en el apartado 4.

💡 Consejo: Zod 4 trae traducciones de sus mensajes genéricos. Si no quieres escribir un mensaje en cada regla, puedes activar el español con z.config(z.locales.es()); entonces z.string().safeParse(3) da “Entrada inválida: se esperaba texto, recibido número”. En este curso escribimos los mensajes a mano porque así dicen exactamente lo que el usuario tiene que hacer (“Nueve cifras, empezando por 6, 7, 8 o 9” es más útil que “Formato no válido”).


3. Del FormData a datos con tipo

Aquí está la primera dificultad real de validar formularios: en un FormData todo es texto. Un <input type="number"> envía "2450", no 2450. Un campo vacío envía "", no null ni undefined. Una casilla sin marcar no envía nada. Un <select> de departamentos envía "30".

La API EMPRESA, en cambio, espera números de verdad y null en los campos opcionales: el departamento INVESTIGACIÓN tiene presupuesto: null, y hay empleados con telefono: null y deptId: null. Tu esquema tiene que hacer de puente.

3.1 Las trampas de z.coerce

z.coerce.number() convierte lo que le llega con Number(valor) antes de validar. Parece la solución, pero mira esta salida real:

coerce.number("")      -> 0
coerce.number("  ")    -> 0
coerce.number("12,5")  -> false (no válido)
coerce.boolean("false")-> true
stringbool("on")       -> true
stringbool("false")    -> false
stringbool(undefined)  -> no válido

Tres lecciones:

  1. Un campo vacío se convierte en 0. Si la comisión es opcional y el usuario la deja en blanco, z.coerce.number() la guarda como cero, que no es lo mismo que “sin comisión”. Y si es obligatoria con .positive(), el mensaje será “debe ser mayor que cero” en vez de “es obligatorio”.
  2. La coma decimal no vale. Number("12,5") es NaN. Un <input type="number"> siempre envía punto, pero un <input type="text"> puede llegar con coma si el usuario es español.
  3. z.coerce.boolean() no sirve para casillas. Convierte cualquier texto no vacío en true, incluido "false". Para casillas y selects de sí/no usa z.stringbool(), que entiende "on", "true", "1", "yes" y sus contrarios. Ojo: una casilla sin marcar no aparece en el FormData, así que necesitas .optional() o .default(false).

La solución para los opcionales es convertir el texto vacío en null antes de validar, con z.preprocess, y luego permitir null con .nullable():

// Convierte '' (campo vacío de un formulario) en null antes de validar.
const vacioANull = (v: unknown) => (typeof v === 'string' && v.trim() === '' ? null : v)

const comision = z.preprocess(
  vacioANull,
  z.coerce.number({ error: 'Escribe un número' }).min(0, 'No puede ser negativa').nullable(),
)

El orden importa: .nullable() deja pasar el null antes de que actúe z.coerce (que lo convertiría en 0).

3.2 El esquema del empleado

Este es el esquema completo que usaremos para dar de alta y editar empleados. Va en lib/esquemas.ts, sin 'use client' ni server-only, porque lo importarán tanto el servidor como el navegador:

// lib/esquemas.ts
import { z } from 'zod'

// Convierte '' (campo vacío de un formulario) en null antes de validar.
const vacioANull = (v: unknown) => (typeof v === 'string' && v.trim() === '' ? null : v)

export const OFICIOS = [
  'DIRECTOR GENERAL', 'DIRECTOR', 'VENDEDOR', 'ADMINISTRATIVO', 'ANALISTA',
  'PROGRAMADOR', 'TÉCNICO SISTEMAS', 'CONTABLE', 'DISEÑADOR', 'OPERARIO', 'BECARIO',
] as const

export const esquemaEmpleado = z
  .object({
    nombre: z.string().trim().min(1, 'El nombre es obligatorio').max(40, 'Máximo 40 caracteres'),
    apellidos: z.string().trim().min(1, 'Los apellidos son obligatorios').max(80, 'Máximo 80 caracteres'),
    email: z.email({ error: 'El email no es válido' }).toLowerCase(),
    telefono: z.preprocess(
      vacioANull,
      z.string().regex(/^[6-9]\d{8}$/, 'Nueve cifras, empezando por 6, 7, 8 o 9').nullable(),
    ),
    fechaAlta: z.preprocess(vacioANull, z.iso.date({ error: 'Fecha no válida' }).nullable()),
    oficio: z.enum(OFICIOS, { error: 'Elige un oficio de la lista' }),
    salario: z.coerce
      .number({ error: 'Escribe un número' })
      .positive('El salario debe ser mayor que cero')
      .max(20000, 'Como mucho 20 000 €'),
    comision: z.preprocess(vacioANull, z.coerce.number({ error: 'Escribe un número' }).min(0, 'No puede ser negativa').nullable()),
    deptId: z.preprocess(vacioANull, z.coerce.number().int().nullable()),
  })
  .refine((e) => e.comision === null || e.oficio === 'VENDEDOR', {
    error: 'Solo los vendedores cobran comisión',
    path: ['comision'],
  })

export type Empleado = z.infer<typeof esquemaEmpleado>
export type EntradaEmpleado = z.input<typeof esquemaEmpleado>

Fíjate en varios detalles:

  • .trim() transforma: " Marta " sale como "Marta", y " " se queda en "", que no pasa .min(1). Un required de HTML no detecta eso: tres espacios cuentan como “relleno”.
  • z.email(...).toLowerCase() valida y normaliza en un paso. Así Marta.Ruiz@Empresa.es y marta.ruiz@empresa.es no acaban siendo dos usuarios.
  • z.enum(OFICIOS) impide que alguien envíe "ASTRONAUTA" modificando el <select> con las DevTools.
  • .refine en el objeto expresa una regla entre campos, y path: ['comision'] dice a qué campo se le cuelga el error.
  • z.infer da el tipo de salida (números, null), y z.input el de entrada (lo que aceptas). Con preprocess y coerce, la entrada es casi todo unknown.

Con datos correctos, todo llega como texto y sale con su tipo:

esquemaEmpleado.safeParse({
  nombre: '  Marta ', apellidos: 'Ruiz Soler', email: 'Marta.Ruiz@Empresa.es', telefono: '',
  fechaAlta: '2026-11-02', oficio: 'PROGRAMADOR', salario: '2450.50', comision: '', deptId: '30',
})
{
  success: true,
  data: {
    nombre: 'Marta',
    apellidos: 'Ruiz Soler',
    email: 'marta.ruiz@empresa.es',
    telefono: null,
    fechaAlta: '2026-11-02',
    oficio: 'PROGRAMADOR',
    salario: 2450.5,
    comision: null,
    deptId: 30
  }
}

3.3 Cuándo se ejecuta un refine de objeto

Un detalle de Zod 4 que sorprende: el refine del objeto no siempre se ejecuta. Probamos tres casos, siempre con un programador que tiene comisión (lo que debería dar error):

Otros errores ¿Sale el error de comisión?
Ninguno Sí: { comision: ['Solo los vendedores cobran comisión'] }
nombre vacío (too_small) Sí, junto al de nombre
salario: 'mucho' (invalid_type) No, solo sale el de salario

La razón: algunos errores, como invalid_type o invalid_value en un enum, significan que el objeto no tiene la forma esperada. Zod no puede garantizar que e.oficio sea un oficio válido, así que no ejecuta tu función. Otros, como too_small, dejan el valor con su tipo correcto y Zod sigue adelante. En la práctica, el usuario corrige el salario, vuelve a enviar y entonces ve el error de la comisión. Es aceptable; solo tenlo en cuenta al escribir pruebas.

💡 Consejo: Si necesitas controlar cuándo se ejecuta una regla, Zod 4 acepta una opción when en refine, que recibe el estado de la validación y decide si se ejecuta. Para la mayoría de formularios no hace falta.


4. Errores por campo desde una Server Action

4.1 El contrato: qué devuelve la acción

Con useActionState, lo que devuelve la acción es el estado del formulario, y el componente se vuelve a pintar con él. Antes de escribir la acción, decide la forma de ese estado. Esta nos sirve para todos los formularios del módulo:

// lib/formulario.ts
import { z } from 'zod'

// Lo que una Server Action de formulario devuelve a useActionState.
export type EstadoFormulario = {
  ok: boolean
  mensaje: string | null
  errores: Record<string, string[]>
  valores: Record<string, string>
}

export const estadoInicial: EstadoFormulario = { ok: false, mensaje: null, errores: {}, valores: {} }
  • errores tiene una lista de mensajes por campo. La clave es el name del control: 'email', o 'lineas.2.cantidad' en un formulario con filas.
  • valores guarda lo que escribió el usuario, tal cual, como texto. Es imprescindible, como verás en el apartado 4.4.
  • mensaje es un aviso general (“Revisa los campos marcados”, “No hay stock suficiente…”).

Todo el estado es serializable (texto, listas, objetos planos): tiene que viajar del servidor al navegador, como viste en el módulo 05.

Y tres funciones de ayuda en el mismo fichero:

// FormData -> objeto plano. Las claves con puntos ("lineas.0.cantidad") se anidan.
// Los ficheros se ignoran: se leen aparte con formData.get().
export function formDataAObjeto(formData: FormData): Record<string, unknown> {
  const raiz: Record<string, unknown> = {}
  for (const [clave, valor] of formData) {
    if (clave.startsWith('$ACTION') || typeof valor !== 'string') continue
    const partes = clave.split('.')
    let nodo: Record<string, unknown> = raiz
    partes.forEach((parte, i) => {
      const ultima = i === partes.length - 1
      if (ultima) {
        nodo[parte] = valor
        return
      }
      const siguienteEsIndice = /^\d+$/.test(partes[i + 1])
      nodo[parte] ??= siguienteEsIndice ? [] : {}
      nodo = nodo[parte] as Record<string, unknown>
    })
  }
  return raiz
}

// Los valores tal y como los escribió el usuario, para volver a pintarlos.
export function valoresTextuales(formData: FormData): Record<string, string> {
  const valores: Record<string, string> = {}
  for (const [clave, valor] of formData) {
    if (!clave.startsWith('$ACTION') && typeof valor === 'string') valores[clave] = valor
  }
  return valores
}

// ZodError -> { 'nombre': [...], 'lineas.1.cantidad': [...] }. Sirve para formularios planos y anidados.
export function erroresPorCampo(error: z.ZodError): Record<string, string[]> {
  const errores: Record<string, string[]> = {}
  for (const issue of error.issues) {
    const clave = issue.path.length ? issue.path.join('.') : '_formulario'
    ;(errores[clave] ??= []).push(issue.message)
  }
  return errores
}

¿Por qué saltar las claves que empiezan por $ACTION? Porque cuando el formulario se envía sin JavaScript, Next añade campos ocultos para identificar la acción ($ACTION_REF_1, $ACTION_KEY…). La documentación de Next lo advierte: si haces Object.fromEntries(formData), esas claves aparecen en el objeto. No hacen daño a un z.object (que por defecto ignora las claves que no conoce), pero ensucian los valores que devuelves.

📘 Concepto: ¿Por qué no Object.fromEntries(formData) y ya está? Funciona para formularios planos, pero pierde los valores repetidos (dos casillas con el mismo name) y no entiende anidamientos. formDataAObjeto es una versión mínima que cubre lo que usamos en el curso. Si necesitas valores repetidos, usa formData.getAll('nombre') para ese campo.

4.2 La capa de acceso a la API

Las escrituras van contra la API EMPRESA, que exige un token. En el módulo 17 usaremos el token del usuario que ha iniciado sesión; de momento, el servidor entra con una cuenta de servicio cuyas credenciales están en variables de entorno, nunca en el código que llega al navegador:

// lib/api.ts
import 'server-only'
import { cacheLife, cacheTag } from 'next/cache'

const API = process.env.API_URL ?? 'http://localhost:3000/api'

export type RespuestaApi<T> =
  | { ok: true; status: number; datos: T }
  | { ok: false; status: number; mensaje: string; detalles?: Record<string, string> }

let token: { valor: string; caduca: number } | null = null

async function obtenerToken(): Promise<string> {
  if (token && token.caduca > Date.now()) return token.valor
  const r = await fetch(`${API}/auth/login`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      email: process.env.API_EMAIL ?? 'ana.garcia@empresa.es',
      password: process.env.API_PASSWORD ?? 'secreto',
    }),
  })
  if (!r.ok) throw new Error(`Login en la API fallido: ${r.status}`)
  const { token: valor, expiraEn } = await r.json()
  token = { valor, caduca: Date.now() + (expiraEn - 60) * 1000 }
  return valor
}

export async function escribir<T>(metodo: 'POST' | 'PUT' | 'PATCH', ruta: string, cuerpo: unknown): Promise<RespuestaApi<T>> {
  const r = await fetch(`${API}${ruta}`, {
    method: metodo,
    headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${await obtenerToken()}` },
    body: JSON.stringify(cuerpo),
  })
  const json = await r.json()
  if (r.ok) return { ok: true, status: r.status, datos: json as T }
  return { ok: false, status: r.status, mensaje: json.mensaje, detalles: json.detalles }
}

export async function leer<T>(ruta: string): Promise<T> {
  const r = await fetch(`${API}${ruta}`)
  if (!r.ok) throw new Error(`GET ${ruta}: ${r.status}`)
  return r.json()
}

escribir no lanza cuando la API responde con un error: devuelve { ok: false, ... }. Así la acción puede decidir qué hacer con un 422 (errores por campo) o un 409 (mensaje general). Las lecturas (leerDepartamentos, leerEmpleado…) las verás en el apartado 7, porque cómo se cachean resulta ser clave para la mejora progresiva.

⚠️ Importante: Recuerda que Next usa por defecto el puerto 3000, el mismo que la API EMPRESA. Arranca Next en otro (npm run dev -- -p 3001) y define API_URL=http://localhost:3000/api en .env.local.

4.3 La acción: validar, escribir, traducir errores

// app/empleados/acciones.ts
'use server'

import { redirect } from 'next/navigation'
import { updateTag } from 'next/cache'
import { esquemaEmpleado } from '@/lib/esquemas'
import { escribir, type EmpleadoApi } from '@/lib/api'
import {
  erroresPorCampo, formDataAObjeto, valoresTextuales, type EstadoFormulario,
} from '@/lib/formulario'

async function guardarEmpleado(
  empId: number | null,
  formData: FormData,
): Promise<EstadoFormulario> {
  const valores = valoresTextuales(formData)

  // 1. Validar con el mismo esquema que usa el cliente
  const resultado = esquemaEmpleado.safeParse(formDataAObjeto(formData))
  if (!resultado.success) {
    return { ok: false, mensaje: 'Revisa los campos marcados', errores: erroresPorCampo(resultado.error), valores }
  }

  // 2. Escribir en la API. Puede rechazar lo que Zod no sabe (email repetido, departamento inexistente)
  //    Sin fecha, no la enviamos: la API pone la de hoy al crear y conserva la que había al editar
  const { fechaAlta, ...resto } = resultado.data
  const cuerpo = fechaAlta ? resultado.data : resto
  const respuesta = empId === null
    ? await escribir<EmpleadoApi>('POST', '/empleados', cuerpo)
    : await escribir<EmpleadoApi>('PUT', `/empleados/${empId}`, cuerpo)

  if (!respuesta.ok) {
    if (respuesta.status === 422 && respuesta.detalles) {
      const errores = Object.fromEntries(Object.entries(respuesta.detalles).map(([c, m]) => [c, [m]]))
      return { ok: false, mensaje: 'Revisa los campos marcados', errores, valores }
    }
    return { ok: false, mensaje: `No se pudo guardar: ${respuesta.mensaje}`, errores: {}, valores }
  }

  // 3. Todo bien: invalidamos la ficha en caché y vamos a ella.
  //    redirect lanza una excepción, así que va fuera de cualquier try
  updateTag(`empleado-${respuesta.datos.empId}`)
  redirect(`/empleados/${respuesta.datos.empId}?guardado=1`)
}

export async function crearEmpleado(_previo: EstadoFormulario, formData: FormData) {
  return guardarEmpleado(null, formData)
}

export async function editarEmpleado(empId: number, _previo: EstadoFormulario, formData: FormData) {
  return guardarEmpleado(empId, formData)
}

La parte nueva respecto al módulo 10 es el paso 2. Zod comprueba la forma de los datos, pero hay reglas que dependen de lo que ya está guardado y que solo conoce la API: que el email no esté repetido o que el departamento exista. La API EMPRESA responde a esos casos con un 422 y un objeto detalles con la misma forma que nuestros errores por campo:

{
  "status": 422,
  "mensaje": "Hay datos no válidos",
  "detalles": { "email": "Ese email ya está en uso" }
}

Así que traducirlo es una línea. El resultado, para el usuario, es indistinguible de un error de Zod: el mensaje aparece debajo del campo email.

Formulario de alta de empleado con el aviso "Revisa los campos marcados" y el error "Ese email ya está en uso" debajo del campo email; el resto de campos conserva lo que escribió el usuario, incluidos los desplegables de oficio y departamento

Figura 2. Un error que solo conoce la API (el email ana.garcia@empresa.es ya existe) se muestra como un error más del campo. Los valores, incluidos los dos <select>, siguen ahí.

editarEmpleado recibe el empId como primer argumento porque el componente le pasa editarEmpleado.bind(null, empId), la técnica que viste en el módulo 10. Es preferible a un <input type="hidden" name="empId">: el valor ligado con bind va cifrado y el usuario no puede cambiarlo editando el HTML.

⚠️ Importante: Aun así, nunca confíes en el identificador para autorizar. Que empId venga cifrado evita que lo cambien en el formulario, no que alguien llame a la acción con otro valor por otra vía. Comprobar que el usuario puede editar a ese empleado es trabajo del módulo 17.

4.4 El componente: errores accesibles y valores que no se pierden

El formulario es un Client Component porque usa useActionState. Un pequeño componente sin directiva pinta los errores y sirve tanto en el servidor como en el cliente:

// components/error-campo.tsx
// Sin 'use client': sirve igual en Server y en Client Components.
export function ErrorCampo({ id, errores }: { id: string; errores?: string[] }) {
  if (!errores?.length) return null
  return (
    <p id={`${id}-error`} className="text-sm text-red-700 mt-1">
      {errores.join('. ')}
    </p>
  )
}

// Atributos de accesibilidad para el control que tiene (o no) errores.
export function ariaError(id: string, errores?: string[]) {
  return errores?.length
    ? { 'aria-invalid': true as const, 'aria-describedby': `${id}-error` }
    : {}
}

export const claseControl =
  'mt-1 block w-full rounded border border-slate-300 px-2 py-1.5 aria-invalid:border-red-600 aria-invalid:bg-red-50'

aria-invalid le dice a un lector de pantalla que el campo tiene un problema, y aria-describedby le dice dónde está el texto que lo explica, para que lo lea al entrar en el campo. De paso, Tailwind 4 tiene la variante aria-invalid:, así que el mismo atributo pinta el borde rojo: estilos y accesibilidad salen de la misma fuente.

Y el formulario (lo que viene del cliente lo explicamos en el apartado 5; de momento fíjate en valor, key y los atributos):

// app/empleados/formulario-empleado.tsx
'use client'

import { useActionState, useEffect, useRef, useState } from 'react'
import { useFormStatus } from 'react-dom'
import { esquemaEmpleado, OFICIOS } from '@/lib/esquemas'
import { erroresPorCampo, estadoInicial, formDataAObjeto, type EstadoFormulario } from '@/lib/formulario'
import { ErrorCampo, ariaError, claseControl } from '@/components/error-campo'

type Props = {
  accion: (previo: EstadoFormulario, formData: FormData) => Promise<EstadoFormulario>
  departamentos: { deptId: number; nombre: string }[]
  inicial?: Record<string, string>
  textoBoton: string
}

function BotonGuardar({ texto }: { texto: string }) {
  const { pending } = useFormStatus()
  return (
    <button disabled={pending} className="rounded bg-indigo-700 px-4 py-2 text-white disabled:opacity-50">
      {pending ? 'Guardando…' : texto}
    </button>
  )
}

export function FormularioEmpleado({ accion, departamentos, inicial = {}, textoBoton }: Props) {
  const [estado, accionFormulario] = useActionState(accion, estadoInicial)
  const [erroresCliente, setErroresCliente] = useState<Record<string, string[]> | null>(null)
  const formRef = useRef<HTMLFormElement>(null)

  // Errores visibles: los del cliente si los hay; si no, los que devolvió el servidor
  const errores = erroresCliente ?? estado.errores
  // Valor que se pinta: lo último que envió el usuario o, la primera vez, el inicial
  const valor = (campo: string) => estado.valores[campo] ?? inicial[campo] ?? ''

  // Validación en el navegador con el MISMO esquema. Si falla, no se llama a la acción.
  function alEnviar(e: React.FormEvent<HTMLFormElement>) {
    const r = esquemaEmpleado.safeParse(formDataAObjeto(new FormData(e.currentTarget)))
    if (!r.success) {
      e.preventDefault()
      setErroresCliente(erroresPorCampo(r.error))
    } else {
      setErroresCliente(null)
    }
  }

  // Lleva el foco al primer campo con error cada vez que cambian los errores
  useEffect(() => {
    formRef.current?.querySelector<HTMLElement>('[aria-invalid="true"]')?.focus()
  }, [errores])

  const primerError = Object.keys(errores)[0]

  return (
    <form key={JSON.stringify(estado.valores)} ref={formRef} action={accionFormulario} onSubmit={alEnviar} className="space-y-4">
      {estado.mensaje && !erroresCliente && (
        <p role="alert" className="rounded bg-red-100 px-3 py-2 text-red-800">{estado.mensaje}</p>
      )}

      <div className="grid grid-cols-2 gap-4">
        <label className="block">Nombre
          <input name="nombre" id="nombre" required maxLength={40} defaultValue={valor('nombre')}
            autoFocus={primerError === 'nombre'} className={claseControl} {...ariaError('nombre', errores.nombre)} />
          <ErrorCampo id="nombre" errores={errores.nombre} />
        </label>
        <label className="block">Apellidos
          <input name="apellidos" id="apellidos" required maxLength={80} defaultValue={valor('apellidos')}
            autoFocus={primerError === 'apellidos'} className={claseControl} {...ariaError('apellidos', errores.apellidos)} />
          <ErrorCampo id="apellidos" errores={errores.apellidos} />
        </label>
        <label className="block">Email
          <input name="email" id="email" type="email" required defaultValue={valor('email')}
            autoFocus={primerError === 'email'} className={claseControl} {...ariaError('email', errores.email)} />
          <ErrorCampo id="email" errores={errores.email} />
        </label>
        <label className="block">Teléfono <span className="text-slate-500">(opcional)</span>
          <input name="telefono" id="telefono" type="tel" inputMode="numeric" defaultValue={valor('telefono')}
            autoFocus={primerError === 'telefono'} className={claseControl} {...ariaError('telefono', errores.telefono)} />
          <ErrorCampo id="telefono" errores={errores.telefono} />
        </label>
        <label className="block">Fecha de alta <span className="text-slate-500">(vacía: hoy)</span>
          <input name="fechaAlta" id="fechaAlta" type="date" defaultValue={valor('fechaAlta')}
            className={claseControl} {...ariaError('fechaAlta', errores.fechaAlta)} />
          <ErrorCampo id="fechaAlta" errores={errores.fechaAlta} />
        </label>
        <label className="block">Oficio
          <select name="oficio" id="oficio" required defaultValue={valor('oficio')}
            className={claseControl} {...ariaError('oficio', errores.oficio)}>
            <option value="">Elige…</option>
            {OFICIOS.map((o) => <option key={o} value={o}>{o}</option>)}
          </select>
          <ErrorCampo id="oficio" errores={errores.oficio} />
        </label>
        <label className="block">Salario mensual (€)
          <input name="salario" id="salario" type="number" step="0.01" min="0.01" required defaultValue={valor('salario')}
            autoFocus={primerError === 'salario'} className={claseControl} {...ariaError('salario', errores.salario)} />
          <ErrorCampo id="salario" errores={errores.salario} />
        </label>
        <label className="block">Comisión (€) <span className="text-slate-500">(solo vendedores)</span>
          <input name="comision" id="comision" type="number" step="0.01" min="0" defaultValue={valor('comision')}
            autoFocus={primerError === 'comision'} className={claseControl} {...ariaError('comision', errores.comision)} />
          <ErrorCampo id="comision" errores={errores.comision} />
        </label>
        <label className="block col-span-2">Departamento
          <select name="deptId" id="deptId" defaultValue={valor('deptId')}
            className={claseControl} {...ariaError('deptId', errores.deptId)}>
            <option value="">Sin departamento</option>
            {departamentos.map((d) => <option key={d.deptId} value={d.deptId}>{d.nombre}</option>)}
          </select>
          <ErrorCampo id="deptId" errores={errores.deptId} />
        </label>
      </div>

      <BotonGuardar texto={textoBoton} />
    </form>
  )
}

¿Por qué hay que devolver valores? Porque en React 19, cuando una acción de formulario termina, React resetea el formulario: cada control vuelve a su defaultValue. Si la acción devolviera solo errores, el usuario vería los mensajes… sobre un formulario vacío. Con defaultValue={valor('nombre')} el valor “por defecto” pasa a ser lo último que escribió, y el reset lo deja como estaba.

¿Y por qué key={JSON.stringify(estado.valores)}? Lo descubrimos probando. Sin esa key, después de un error del servidor los <input> conservaban su valor, pero los dos <select> volvían a la primera opción. Esta es la salida real de Playwright tras el error de email repetido, sin la key:

valores tras la respuesta: nombre=Marta | apellidos=Ruiz Soler | email=ana.garcia@empresa.es |
  telefono= | fechaAlta=2026-11-02 | oficio= | salario=2450 | comision= | deptId=

oficio y deptId vacíos, aunque el estado traía PROGRAMADOR y 30. El motivo: el reset nativo del formulario devuelve cada <select> a la opción marcada con el atributo selected del HTML, y React solo fija ese atributo cuando monta el <select>; en una actualización cambia la selección actual, pero no la “por defecto”. Con la key, cada respuesta con valores distintos monta un formulario nuevo, React marca la opción correcta y el reset ya no la pierde. Con la key, la misma prueba da:

valores tras la respuesta: nombre=Marta | apellidos=Ruiz Soler | email=ana.garcia@empresa.es |
  telefono= | fechaAlta= | oficio=PROGRAMADOR | salario=2450 | comision= | deptId=30

⚠️ Importante: Este fallo no da ningún error ni aviso. Si tu formulario tiene <select> y devuelves valores desde una acción, pruébalo explícitamente: provoca un error del servidor y mira si los desplegables siguen en su sitio.

El foco. Cuando aparecen errores, el foco va al primer campo marcado, para que quien navega con teclado o con lector de pantalla no tenga que buscarlo. Lo hacemos dos veces y por dos vías: con useEffect (cuando hay JavaScript) y con autoFocus en el primer campo con error. Lo segundo funciona sin JavaScript, porque React genera el atributo autofocus en el HTML del servidor; lo comprobamos en la respuesta HTML de la prueba sin JavaScript del apartado 6, donde aparece en el <input name="nombre">.

4.5 Las páginas

La página de alta es un Server Component que lee los departamentos y monta el formulario. La de edición convierte el empleado de la API (con números y null) a lo que entiende un <input>: texto. Así un empleado como el 160, que tiene telefono: null y deptId: null, aparece con el teléfono vacío y “Sin departamento”, en vez de con la palabra null.

// app/empleados/[id]/editar/page.tsx
import { notFound } from 'next/navigation'
import { leerDepartamentos, leerEmpleado, type EmpleadoApi } from '@/lib/api'
import { editarEmpleado } from '../../acciones'
import { FormularioEmpleado } from '../../formulario-empleado'

// Lo que viene de la API (números, null) pasado a lo que entiende un <input>: texto
function aValoresIniciales(e: EmpleadoApi): Record<string, string> {
  const texto = (v: string | number | null) => (v === null ? '' : String(v))
  return {
    nombre: e.nombre, apellidos: e.apellidos, email: e.email, telefono: texto(e.telefono),
    fechaAlta: e.fechaAlta, oficio: e.oficio, salario: texto(e.salario),
    comision: texto(e.comision), deptId: texto(e.deptId),
  }
}

// Con al menos un valor conocido, params puede leerse fuera de <Suspense>.
// Los demás identificadores se generan en la primera visita y quedan en caché.
export async function generateStaticParams() {
  return [{ id: '100' }]
}

export default async function EditarEmpleado({ params }: PageProps<'/empleados/[id]/editar'>) {
  const empId = Number((await params).id)
  if (!Number.isInteger(empId)) notFound()
  const [empleado, departamentos] = await Promise.all([leerEmpleado(empId), leerDepartamentos()])
  if (!empleado) notFound()

  return (
    <>
      <h1 className="text-2xl font-bold mb-4">Editar a {empleado.nombre} {empleado.apellidos}</h1>
      <FormularioEmpleado
        accion={editarEmpleado.bind(null, empId)}
        departamentos={departamentos}
        inicial={aValoresIniciales(empleado)}
        textoBoton="Guardar cambios"
      />
    </>
  )
}

Valores iniciales reales del empleado 160 en el formulario:

nombre=Tomás | apellidos=Reyes Fuentes | email=tomas.reyes@empresa.es | telefono= |
fechaAlta=2025-09-01 | oficio=BECARIO | salario=900 | comision= | deptId=

Que no haya <Suspense> ni en esta página ni en la de alta no es un descuido: es lo que permite que funcionen sin JavaScript. Lo explicamos en el apartado 7.


5. El mismo esquema en el cliente

Ir al servidor para descubrir que el teléfono tiene cinco cifras es lento e innecesario. Como el esquema vive en lib/esquemas.ts y no depende de nada del servidor, el formulario lo importa y valida antes de enviar:

function alEnviar(e: React.FormEvent<HTMLFormElement>) {
  const r = esquemaEmpleado.safeParse(formDataAObjeto(new FormData(e.currentTarget)))
  if (!r.success) {
    e.preventDefault()
    setErroresCliente(erroresPorCampo(r.error))
  } else {
    setErroresCliente(null)
  }
}

La pieza clave es e.preventDefault(). Un <form> con action={funcion} dispara primero el evento submit; si un manejador onSubmit lo cancela, React no llama a la acción. Lo comprobamos: con nombre en blanco, teléfono 12345 y comisión para un programador, aparecieron los tres errores y Playwright no registró ninguna petición POST. El foco quedó en nombre.

Formulario de alta con tres errores marcados en rojo, detectados en el navegador sin ir al servidor: el nombre es obligatorio, el teléfono necesita nueve cifras y solo los vendedores cobran comisión

Figura 3. Errores detectados en el navegador con el mismo esquema que usa el servidor. No ha salido ninguna petición.

Fíjate en que el required de HTML no paró el envío: el nombre tenía tres espacios, y para HTML eso es un campo relleno. Zod, con su .trim(), sí lo detecta.

Si la validación del cliente pasa, el envío sigue su curso, la acción vuelve a validar con el mismo esquema y, además, consulta a la API. ¿No es redundante validar dos veces? No: la del cliente es para el usuario honrado que se equivoca; la del servidor, para todos los demás.

💡 Consejo: Importar Zod en un Client Component añade la biblioteca al JavaScript del navegador. Para formularios internos como este no suele importar, pero si el peso es crítico, Zod 4 ofrece una variante modular, zod/mini, con la misma lógica y una API funcional que permite a Turbopack eliminar lo que no usas. Medirás el efecto con el analizador de paquetes en el módulo 20.

⚠️ Importante: Si el esquema del servidor y el del cliente se separan (uno exige teléfono y otro no, por ejemplo), el usuario verá errores incoherentes. Por eso es un solo fichero importado desde los dos lados. Y por eso ese fichero no puede llevar import 'server-only' ni leer process.env con secretos.


6. Mejora progresiva: la prueba sin JavaScript

“Mejora progresiva” significa que la página funciona con HTML y nada más, y que el JavaScript la mejora cuando llega. Con las Server Actions, Next y React lo ponen fácil: un <form action={accion}> se convierte en un formulario HTML normal que el navegador sabe enviar por sí solo. Pero “fácil” no es “automático”, y la única forma de saberlo es probarlo. Eso hicimos: el mismo formulario, con Playwright y javaScriptEnabled: false.

6.1 Cómo probarlo tú

Tienes tres opciones, de más rápida a más fiable:

  1. Chrome DevTools: abre las DevTools, pulsa Ctrl+Shift+P, escribe Disable JavaScript y recarga. Se mantiene mientras las DevTools estén abiertas.
  2. Firefox: about:config, javascript.enabled a false.
  3. Playwright, que es lo que usamos aquí y lo que verás en el módulo 21:
import { chromium } from '@playwright/test'

const navegador = await chromium.launch()
const contexto = await navegador.newContext({ javaScriptEnabled: false })
const pagina = await contexto.newPage()
pagina.on('request', (r) => {
  if (r.method() === 'POST') console.log('POST', r.headers()['next-action'] ? 'con Next-Action' : 'sin Next-Action')
})
await pagina.goto('http://127.0.0.1:3001/empleados/nuevo')
// ...rellenar y pulsar el botón

Hay una cuarta situación, muy real, en la que tu página no tiene JavaScript: los primeros segundos de carga en un móvil lento, antes de que llegue y se ejecute el paquete. Si el usuario pulsa “Guardar” en ese momento, el formulario se envía sin JavaScript. Por eso esto no es solo para los pocos que lo desactivan.

6.2 Qué pasa por dentro

El HTML que genera el servidor para nuestro formulario tiene un <form method="post"> con enctype="multipart/form-data" y unos campos ocultos que añade Next (salida real):

inputs ocultos $ACTION: name="$ACTION_REF_1" name="$ACTION_1:0" name="$ACTION_1:1" name="$ACTION_KEY"
form method/action: post  enctype=multipart/form-data

Esos campos identifican la acción y su estado previo de useActionState. Sin JavaScript, el navegador envía el formulario como siempre se ha hecho, Next reconoce los campos, ejecuta la acción y responde. Con JavaScript, React intercepta el envío y hace un fetch con la cabecera Next-Action. Estas son las peticiones reales que vimos:

Situación Petición Respuesta
Con JS, error de validación POST con Next-Action, multipart/form-data 200 text/x-component
Con JS, alta correcta (con redirect) POST con Next-Action 200 text/x-component (la redirección va dentro del payload)
Sin JS, error de validación POST sin Next-Action, multipart/form-data 200 text/html, la página entera
Sin JS, alta correcta POST sin Next-Action 303, Location: /empleados/163?guardado=1

Dos columnas comparan el envío sin JavaScript (POST normal, respuesta 200 HTML con errores o 303 con redirección) y con JavaScript (POST con cabecera Next-Action, respuestas 200 text/x-component)

Figura 4. Tu código es el mismo en los dos casos: la acción, el esquema y el estado. Cambia quién transporta los datos.

El 303 See Other es el código correcto tras un POST: le dice al navegador “el resultado está en esta otra URL; pídela con GET”. Así, si el usuario recarga la ficha, no vuelve a enviar el formulario (el clásico aviso “¿Reenviar el formulario?”).

6.3 El resultado

Con JavaScript desactivado, enviamos nombre en blanco, teléfono 12345 y el email repetido de Ana García. La respuesta fue una página completa con los errores y los valores intactos:

URL: /empleados/nuevo
errores: [
  'nombre-error: El nombre es obligatorio',
  'telefono-error: Nueve cifras, empezando por 6, 7, 8 o 9'
]
valores: nombre=    | apellidos=Ruiz Soler | email=ana.garcia@empresa.es | telefono=12345 |
  fechaAlta= | oficio=VENDEDOR | salario=2450 | comision= | deptId=20
autofocus en el HTML: nombre

Formulario de alta tras enviarlo sin JavaScript: aviso "Revisa los campos marcados", errores en nombre y teléfono, y los demás campos con lo que escribió el usuario

Figura 5. La misma pantalla, sin una sola línea de JavaScript ejecutada. La validación del cliente no ha actuado (no hay JS), pero la del servidor sí.

Observa que el error del email no aparece: Zod encontró errores antes, así que la acción no llegó a llamar a la API. Al corregir nombre y teléfono, el siguiente envío llegó a la API y, con un email nuevo, acabó en 303 hacia /empleados/163?guardado=1, con la ficha visible. La edición del empleado 160 sin JavaScript también funcionó: 303 hacia /empleados/160?guardado=1.

¿Y qué se pierde sin JavaScript? Exactamente lo que es mejora: la validación instantánea del cliente, el texto “Guardando…” del botón, el foco con useEffect (aunque autofocus lo suple) y la respuesta sin recargar. Nada esencial.


7. El enemigo de la mejora progresiva: <Suspense> 🆕 16.0

🆕 Desde Next.js 16.0. 'use cache', cacheLife, cacheTag y updateTag no existen de forma estable en Next.js 15. Si usas Next.js 15: no rodees el formulario con <Suspense> ni pongas un loading.tsx en su ruta; la página se renderiza entera en cada petición (ƒ), el HTML llega completo y funciona sin JavaScript. Tras guardar, usa revalidatePath en lugar de updateTag.

7.1 El problema

La primera versión de nuestra página de alta era la que escribirías siguiendo el módulo 08: los departamentos se piden a la API dentro de un componente asíncrono, rodeado de <Suspense> para no bloquear la página. Con JavaScript funcionaba perfectamente. Sin JavaScript, Playwright no pudo ni escribir en el primer campo:

locator.fill: Timeout 30000ms exceeded.
  - locator resolved to <input value="" id="nombre" required="" name="nombre" .../>
  - element is not visible

El <input> estaba en el HTML, pero no era visible. Mirando el HTML que llegó:

<div hidden id="S:0"     <- el formulario, dentro de un div oculto
Cargando formulario…     <- el fallback, visible
$RC("B:0","S:0")         <- el script que intercambia uno por otro

Diagrama del HTML en streaming: el fallback visible, el formulario dentro de un div hidden y un script que los intercambia; con JavaScript el formulario aparece, sin JavaScript el usuario ve "Cargando formulario..." para siempre

Figura 6. El *streaming de React necesita un pequeño script para colocar cada parte en su sitio. Sin JavaScript, lo que llega en streaming se queda oculto.*

Así funciona el streaming que viste en el módulo 02: el servidor envía primero el fallback y, cuando los datos están listos, envía el contenido real dentro de un <div hidden> junto a un script ($RC) que lo mueve a su sitio. Sin JavaScript, ese script no se ejecuta. El usuario ve “Cargando formulario…” para siempre.

⚠️ Importante: Con cacheComponents (activado por defecto en los proyectos nuevos de Next.js 16), cualquier dato sin caché o de la petición (params sin generateStaticParams, searchParams, cookies()) obliga a usar <Suspense>, o el build falla. Si el formulario queda dentro de ese <Suspense>, deja de funcionar sin JavaScript. Es el error más fácil de cometer en este módulo.

7.2 La solución: que el formulario vaya en la parte estática

Si los datos que necesita el formulario salen de la caché, Next los resuelve al hacer el prerender y el formulario entero va en la parte estática de la página, sin <Suspense> y sin streaming. Los departamentos, los clientes o los productos cambian poco: son candidatos perfectos para 'use cache', como viste en el módulo 09:

// lib/api.ts (continuación)
export async function leerDepartamentos() {
  'use cache'
  cacheLife('hours')
  cacheTag('departamentos')
  return (await leer<Lista<Departamento>>('/departamentos?tamano=100')).contenido
}

export async function leerEmpleado(id: number) {
  'use cache'
  cacheLife('hours')
  cacheTag(`empleado-${id}`)
  return leer<EmpleadoApi>(`/empleados/${id}`).catch(() => null)
}
// app/empleados/nuevo/page.tsx
import { leerDepartamentos } from '@/lib/api'
import { crearEmpleado } from '../acciones'
import { FormularioEmpleado } from '../formulario-empleado'

// Sin <Suspense>: los departamentos salen de la caché ('use cache'), así que
// el formulario completo va en el HTML estático y funciona sin JavaScript.
export default async function NuevoEmpleado() {
  const departamentos = await leerDepartamentos()
  return (
    <>
      <h1 className="text-2xl font-bold mb-4">Alta de empleado</h1>
      <FormularioEmpleado accion={crearEmpleado} departamentos={departamentos} textoBoton="Dar de alta" />
    </>
  )
}

Tuvimos que cambiar una cosa más. La primera versión ponía como fecha de alta por defecto new Date(), la de hoy. En una página estática, “hoy” sería el día del build. Lo resolvimos en el esquema: la fecha es opcional, y si llega vacía la acción no la envía y la API pone la del día.

Para las páginas con [id], generateStaticParams con al menos un identificador permite leer params fuera de <Suspense>; los demás identificadores se generan en la primera visita y se guardan. El resultado del build:

Route (app)                   Revalidate  Expire
├ ○ /departamentos/nuevo
├   /empleados/[id]
│ ├ ◐ /empleados/[id]
│ └ ◐ /empleados/100                 15m      1d
├   /empleados/[id]/editar
│ ├ ◐ /empleados/[id]/editar
│ └ ○ /empleados/100/editar           1h      1d
├ ○ /empleados/nuevo                  1h      1d
└ ○ /pedidos/nuevo                    1m      1h

Y la prueba sin JavaScript, repetida:

bloques <div hidden id="S:..."> en el HTML: 0 | form visible: true
...
E. Sin JS, editar el empleado 160 (telefono y deptId nulos)
  primera visita, form visible: true

El empleado 160 no estaba en generateStaticParams, y aun así su formulario de edición llegó completo en la primera visita.

Una última pieza: la ficha (/empleados/[id]) muestra un aviso “Empleado guardado” que depende de searchParams, un dato de la petición. Ese aviso sí va en su propio <Suspense>: sin JavaScript no se ve, pero la ficha sí. Es una degradación aceptable: el aviso es un adorno, la ficha es el contenido.

// En app/empleados/[id]/page.tsx
async function Aviso({ searchParams }: { searchParams: Promise<Record<string, string | string[] | undefined>> }) {
  const s = await searchParams
  const texto = s.guardado ? 'Empleado guardado.' : s.foto ? 'Foto actualizada.' : null
  return texto && <p role="status" className="rounded bg-green-100 px-3 py-2 text-green-800">{texto}</p>
}

// ...y en el JSX de la página:
<Suspense><Aviso searchParams={searchParams} /></Suspense>

7.3 Leer lo que acabas de escribir: updateTag

Si la ficha del empleado está en caché, después de editarlo tienes que invalidarla, o el usuario verá los datos antiguos. Para eso la acción llama a updateTag(empleado-${id}) antes de redirect, como viste en el módulo 10. Lo comprobamos con y sin JavaScript sobre empleados cuya ficha se había generado en una visita (101 y 102): tras subir una foto y tras cambiar el teléfono, la página de destino mostró el dato nuevo en los dos casos.

Encontramos una excepción que debes conocer. Con la ficha del empleado 100, la única generada en el build por generateStaticParams, la primera carga completa después de guardar mostró la versión del build (sin el cambio); la siguiente, ya la nueva. Con JavaScript, la respuesta de la acción traía el dato nuevo, pero al recargar la página apareció una vez la versión antigua. Con las fichas generadas bajo demanda no ocurrió. La documentación de updateTag dice que la siguiente petición espera a los datos frescos, así que lo consideramos un comportamiento a vigilar en las próximas versiones de 16.4.x.

💡 Consejo: En las rutas de edición, usa en generateStaticParams un solo identificador de ejemplo (lo exige cacheComponents), no la lista completa: cuantas menos páginas de registros editables se generen en el build, menos te afecta lo anterior. Y si en tu aplicación ves datos antiguos justo después de guardar, comprueba primero si esa página salió del build.


8. Subida de ficheros

Un <input type="file"> dentro de un formulario con Server Action funciona sin configuración: el navegador envía el fichero en el multipart/form-data y la acción lo recibe como un objeto File estándar con formData.get('foto').

8.1 El esquema del fichero

Zod 4 tiene z.file(), que valida objetos File:

// lib/esquemas.ts (continuación)
export const TIPOS_FOTO = ['image/png', 'image/jpeg', 'image/webp'] as const

export const esquemaFoto = z
  .file({ error: 'Elige una imagen' })
  .min(1, 'El fichero está vacío')
  .max(1_000_000, 'La foto no puede pasar de 1 MB')
  .mime([...TIPOS_FOTO], 'Solo PNG, JPEG o WebP')

Salida real con cuatro casos:

null -> [ 'Elige una imagen' ]
(sin nombre) -> [ 'El fichero está vacío', 'Solo PNG, JPEG o WebP' ]
notas.txt -> [ 'Solo PNG, JPEG o WebP' ]
grande.png -> [ 'La foto no puede pasar de 1 MB' ]

El caso “sin nombre” es importante: si el usuario pulsa “Subir” sin elegir nada (y has quitado el required), el navegador no envía null, sino un File vacío, sin nombre y de tipo application/octet-stream. .min(1) lo detecta.

8.2 No te fíes de file.type

.mime() comprueba file.type, que es lo que dice el navegador, y el navegador lo deduce de la extensión. Si alguien renombra script.php a script.png, file.type será image/png. Por eso la acción comprueba además los primeros bytes del fichero, la “firma” que tiene cada formato:

// app/empleados/acciones.ts (continuación)
import { mkdir, writeFile, rm } from 'node:fs/promises'
import path from 'node:path'
import { esquemaFoto } from '@/lib/esquemas'

const CARPETA_FOTOS = path.join(process.cwd(), 'subidas')
const EXTENSIONES = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp' } as const

// El tipo que dice el navegador (file.type) se puede falsear: miramos los primeros bytes.
function tipoReal(b: Uint8Array): keyof typeof EXTENSIONES | null {
  if (b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47) return 'image/png'
  if (b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return 'image/jpeg'
  const ascii = (i: number, j: number) => String.fromCharCode(...b.slice(i, j))
  if (ascii(0, 4) === 'RIFF' && ascii(8, 12) === 'WEBP') return 'image/webp'
  return null
}

export async function subirFoto(empId: number, _previo: EstadoFormulario, formData: FormData): Promise<EstadoFormulario> {
  const resultado = esquemaFoto.safeParse(formData.get('foto'))
  if (!resultado.success) {
    return { ok: false, mensaje: null, errores: { foto: resultado.error.issues.map((i) => i.message) }, valores: {} }
  }
  const fichero = resultado.data
  const bytes = new Uint8Array(await fichero.arrayBuffer())
  const tipo = tipoReal(bytes)
  if (!tipo) {
    return { ok: false, mensaje: null, errores: { foto: ['El contenido no es una imagen PNG, JPEG o WebP'] }, valores: {} }
  }

  await mkdir(CARPETA_FOTOS, { recursive: true })
  // Nombre decidido por el servidor: nunca uses fichero.name para construir la ruta
  for (const ext of Object.values(EXTENSIONES)) {
    await rm(path.join(CARPETA_FOTOS, `emp-${empId}.${ext}`), { force: true })
  }
  await writeFile(path.join(CARPETA_FOTOS, `emp-${empId}.${EXTENSIONES[tipo]}`), bytes)
  console.log(`[foto] emp-${empId}: ${fichero.name} (${fichero.size} bytes, dice ${fichero.type}, es ${tipo})`)
  updateTag(`foto-${empId}`)

  redirect(`/empleados/${empId}?foto=1`)
}

Resultados reales, enviando sin JavaScript:

script.png: errores= [ 'foto-error: El contenido no es una imagen PNG, JPEG o WebP' ]
grande.png: errores= [ 'foto-error: La foto no puede pasar de 1 MB' ]
avatar.png: URL=/empleados/100?foto=1

script.png pasó .mime() (el navegador dijo image/png), pero no la comprobación de bytes.

⚠️ Importante: Nunca construyas la ruta del fichero con fichero.name. Un nombre como ../../app/page.tsx sobrescribiría tu código. El servidor decide el nombre (emp-100.png) y la extensión sale del tipo real, no de lo que envió el usuario.

8.3 Dónde guardar los ficheros

Guardamos las fotos en subidas/, fuera de public/. Hay dos razones. La primera, que public/ es parte del código de tu aplicación: en un despliegue con Docker o en Vercel se reconstruye desde el repositorio y lo subido se pierde, y en muchas plataformas ni siquiera se puede escribir en él. La segunda, que todo lo que está en public/ es accesible por URL sin ningún control.

Para mostrar la foto, la ficha la lee y la incrusta como data URL. Es sencillo y funciona para imágenes pequeñas como un avatar; en el módulo 12 la serviremos con un Route Handler, con su propia URL y cabeceras de caché.

// En app/empleados/[id]/page.tsx
async function fotoComoDataUrl(empId: number): Promise<string | null> {
  'use cache'
  cacheTag(`foto-${empId}`)
  for (const [ext, tipo] of [['png', 'image/png'], ['jpg', 'image/jpeg'], ['webp', 'image/webp']]) {
    try {
      const bytes = await readFile(path.join(process.cwd(), 'subidas', `emp-${empId}.${ext}`))
      return `data:${tipo};base64,${bytes.toString('base64')}`
    } catch {}
  }
  return null
}

En producción real, los ficheros van a un almacenamiento de objetos (Amazon S3, Cloudflare R2, Vercel Blob…). La acción sería la misma; solo cambia writeFile por la llamada al servicio.

8.4 El límite de 1 MB de las Server Actions

Next limita el cuerpo de las peticiones a Server Actions a 1 MB por defecto, para que nadie pueda tumbar el servidor enviando ficheros gigantes. Una foto de móvil pasa de eso con facilidad. El límite se cambia en next.config.ts, y fíjate en que la opción sigue bajo experimental en Next.js 16.4:

// next.config.ts
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  cacheComponents: true,
  experimental: {
    serverActions: {
      bodySizeLimit: '2mb',
    },
  },
}

export default nextConfig

¿Por qué 2 MB si la foto puede tener como mucho 1 MB? Porque el límite se aplica a todo el cuerpo: el fichero más el resto de campos y la codificación multipart. Deja margen y que sea Zod quien dé el mensaje amable.

¿Y qué pasa si alguien envía más? Subimos un fichero de 3 MB. En el registro del servidor:

⨯ Error: Body exceeded 2mb limit.
To configure the body size limit for Server Actions, see: https://nextjs.org/docs/app/api-reference/next-config-js/serverActions#bodysizelimit
    at ignore-listed frames {
  statusCode: 413
}

Y en el navegador, nada amable: sin JavaScript, Chromium mostró su página de error de conexión (chrome-error://chromewebdata/); con JavaScript, el botón se quedó en “Subiendo enorme.png…” sin ningún mensaje (lo observamos durante más de 8 segundos). La acción ni siquiera llega a ejecutarse, así que no hay estado que devolver.

Por eso merece la pena validar el tamaño también en el cliente, al elegir el fichero:

// app/empleados/[id]/foto/formulario-foto.tsx
'use client'

import { useActionState, useEffect, useState } from 'react'
import { useFormStatus } from 'react-dom'
import { estadoInicial, type EstadoFormulario } from '@/lib/formulario'
import { esquemaFoto, TIPOS_FOTO } from '@/lib/esquemas'
import { ErrorCampo, ariaError } from '@/components/error-campo'

function BotonSubir() {
  const { pending, data } = useFormStatus()
  const fichero = data?.get('foto')
  return (
    <button disabled={pending} className="rounded bg-indigo-700 px-4 py-2 text-white disabled:opacity-50">
      {pending && fichero instanceof File ? `Subiendo ${fichero.name}…` : 'Subir foto'}
    </button>
  )
}

export function FormularioFoto({ accion }: { accion: (p: EstadoFormulario, f: FormData) => Promise<EstadoFormulario> }) {
  const [estado, accionFormulario] = useActionState(accion, estadoInicial)
  const [vistaPrevia, setVistaPrevia] = useState<string | null>(null)
  const [errorCliente, setErrorCliente] = useState<string[] | null>(null)
  const errores = errorCliente ?? estado.errores.foto

  // Libera la URL temporal cuando cambia o se desmonta el componente
  useEffect(() => () => { if (vistaPrevia) URL.revokeObjectURL(vistaPrevia) }, [vistaPrevia])

  function alElegir(e: React.ChangeEvent<HTMLInputElement>) {
    const fichero = e.target.files?.[0]
    if (!fichero) return setVistaPrevia(null)
    const r = esquemaFoto.safeParse(fichero)
    setErrorCliente(r.success ? null : r.error.issues.map((i) => i.message))
    setVistaPrevia(r.success ? URL.createObjectURL(fichero) : null)
  }

  return (
    <form action={accionFormulario} className="space-y-4">
      <label className="block">Imagen (PNG, JPEG o WebP, máximo 1 MB)
        <input type="file" name="foto" id="foto" accept={TIPOS_FOTO.join(',')} required onChange={alElegir}
          className="mt-1 block" {...ariaError('foto', errores)} />
      </label>
      <ErrorCampo id="foto" errores={errores} />
      {vistaPrevia && <img src={vistaPrevia} alt="Vista previa" className="size-32 rounded-full object-cover" />}
      <BotonSubir />
    </form>
  )
}

Tres mejoras con JavaScript, ninguna imprescindible:

  • Validación al elegir. El mismo esquemaFoto funciona en el navegador, porque File existe en los dos sitios. Con el fichero de 1,5 MB, el error “La foto no puede pasar de 1 MB” apareció nada más elegirlo.
  • Vista previa. URL.createObjectURL crea una URL temporal que apunta al fichero en memoria, sin subirlo. Hay que liberarla con revokeObjectURL.
  • Nombre en el botón. useFormStatus() devuelve, además de pending, el data (FormData) del envío en curso. Lo usamos para escribir “Subiendo avatar.png…”.

Página de subida de foto con el fichero avatar.png elegido y una vista previa circular de la imagen antes de subirla

Figura 7. Vista previa con URL.createObjectURL: la imagen todavía no ha salido del navegador.

Ficha de Ana García con el aviso "Foto actualizada", la foto circular con sus iniciales, su oficio, departamento, email y teléfono

Figura 8. La ficha tras subir la foto, con el aviso de ?foto=1. Ojo: la ficha del 100 es la que salió del build, y en la primera carga tras subir la foto vimos todavía “Sin foto” (el comportamiento del apartado 7.3); esta captura es de una carga posterior.

💡 Consejo: Un <input type="file"> no se puede rellenar desde el código por seguridad: no existe defaultValue para ficheros. Cuando la acción devuelve un error, el usuario tiene que volver a elegir el fichero. Por eso los formularios con ficheros suelen separar la subida del resto de datos, como hacemos aquí con una página propia para la foto.


9. Formularios complejos: un pedido con líneas

Un pedido de la API EMPRESA tiene un cliente y una lista de líneas, cada una con producto, cantidad y descuento:

{ "clienteId": 1001, "lineas": [ { "productoId": 1, "cantidad": 2, "descuento": 0 } ] }

Es el típico formulario “maestro-detalle”, y concentra los problemas difíciles: campos anidados, filas que se añaden y quitan, errores por fila y reglas sobre la lista entera.

9.1 Nombres con puntos y un esquema anidado

Cada control de una fila lleva un name con su posición: lineas.0.productoId, lineas.0.cantidad… formDataAObjeto los convierte en un objeto anidado. Esta es la salida real con tres filas, la segunda vacía:

{"clienteId":"1001","lineas":[{"productoId":"1","cantidad":"2","descuento":""},
  {"productoId":"","cantidad":"","descuento":""},{"productoId":"1","cantidad":"0","descuento":"80"}]}

El esquema tiene que decidir qué hacer con las filas vacías. Si las elimina antes de validar, los índices cambian y el error de la tercera fila aparecería en la segunda. Por eso las convertimos en null, validamos, y solo después las quitamos:

// lib/esquemas.ts (continuación)
const esquemaLinea = z.object({
  productoId: z.coerce.number({ error: 'Elige un producto' }).int().positive('Elige un producto'),
  cantidad: z.coerce.number({ error: 'Escribe un número' }).int('Sin decimales').min(1, 'Al menos 1').max(99, 'Como mucho 99'),
  descuento: z.preprocess(vacioANull, z.coerce.number().min(0, '0-50 %').max(50, '0-50 %').nullable()),
})

// Una línea está "vacía" si el usuario no ha tocado ni producto ni cantidad.
const lineaVacia = (l: unknown) =>
  typeof l === 'object' && l !== null &&
  !(l as Record<string, unknown>).productoId && !(l as Record<string, unknown>).cantidad

export const esquemaPedido = z.object({
  clienteId: z.coerce.number({ error: 'Elige un cliente' }).int().positive('Elige un cliente'),
  lineas: z.preprocess(
    // Las filas vacías pasan a null para que los índices sigan siendo los de la pantalla.
    (v) => (Array.isArray(v) ? v.map((l) => (lineaVacia(l) ? null : l)) : v),
    z
      .array(esquemaLinea.nullable())
      .transform((ls) => ls.filter((l) => l !== null))
      .refine((ls) => ls.length > 0, 'El pedido debe tener al menos una línea')
      .refine((ls) => new Set(ls.map((l) => l.productoId)).size === ls.length, 'Hay productos repetidos'),
  ),
})

export type Pedido = z.infer<typeof esquemaPedido>

Resultados reales con erroresPorCampo:

{ 'lineas.2.cantidad': [ 'Al menos 1' ], 'lineas.2.descuento': [ '0-50 %' ] }      <- índice 2: el de la pantalla
{"clienteId":1001,"lineas":[{"productoId":1,"cantidad":2,"descuento":null},
  {"productoId":3,"cantidad":1,"descuento":10}]}                                   <- válido, sin la fila vacía
{ lineas: [ 'Hay productos repetidos' ] }                                           <- regla de la lista

Las reglas sobre la lista entera (al menos una línea, sin productos repetidos) van en un refine del array, y su error queda en la clave lineas. Igual que en el apartado 3.3, esos refine no se ejecutan mientras alguna fila tenga errores: transform y refine solo actúan sobre datos que ya han pasado la validación.

9.2 Filas que se añaden y se quitan, también sin JavaScript

La solución habitual para “añadir línea” es un botón con onClick que suma una fila al estado de React. Sin JavaScript no hace nada. Hay otra forma, que funciona en los dos casos: un botón de envío con name y value. Cuando pulsas un <button name="intencion" value="añadir">, su par intencion=añadir viaja en el FormData junto con el resto de campos, y la acción sabe qué botón se pulsó:

// app/pedidos/nuevo/acciones.ts
'use server'

import { redirect } from 'next/navigation'
import { updateTag } from 'next/cache'
import { esquemaPedido } from '@/lib/esquemas'
import { escribir } from '@/lib/api'
import { erroresPorCampo, formDataAObjeto, valoresTextuales, type EstadoFormulario } from '@/lib/formulario'

const MAX_FILAS = 10

// Quita la fila i y renumera las siguientes: lineas.3.x pasa a ser lineas.2.x
function quitarFila(valores: Record<string, string>, i: number): Record<string, string> {
  const nuevos: Record<string, string> = {}
  for (const [clave, valor] of Object.entries(valores)) {
    const m = clave.match(/^lineas\.(\d+)\.(.+)$/)
    if (!m) { nuevos[clave] = valor; continue }
    const n = Number(m[1])
    if (n === i) continue
    nuevos[`lineas.${n > i ? n - 1 : n}.${m[2]}`] = valor
  }
  return nuevos
}

export async function crearPedido(_previo: EstadoFormulario, formData: FormData): Promise<EstadoFormulario> {
  const intencion = String(formData.get('intencion') ?? 'guardar')
  let valores = valoresTextuales(formData)
  delete valores.intencion
  const filas = Number(valores._filas) || 3

  // Botones que solo cambian el formulario: no validan ni escriben nada
  if (intencion === 'añadir') {
    return { ok: false, mensaje: null, errores: {}, valores: { ...valores, _filas: String(Math.min(filas + 1, MAX_FILAS)) } }
  }
  if (intencion.startsWith('quitar-')) {
    valores = quitarFila(valores, Number(intencion.slice(7)))
    return { ok: false, mensaje: null, errores: {}, valores: { ...valores, _filas: String(Math.max(filas - 1, 1)) } }
  }

  const r = esquemaPedido.safeParse(formDataAObjeto(formData))
  if (!r.success) return { ok: false, mensaje: 'Revisa el pedido', errores: erroresPorCampo(r.error), valores }

  const respuesta = await escribir<{ pedidoId: number }>('POST', '/pedidos', {
    clienteId: r.data.clienteId,
    lineas: r.data.lineas.map((l) => ({ productoId: l.productoId, cantidad: l.cantidad, descuento: l.descuento ?? 0 })),
  })
  if (!respuesta.ok) {
    // 409 = sin stock o descatalogado: es un error de todo el pedido, no de un campo
    return { ok: false, mensaje: respuesta.mensaje, errores: {}, valores }
  }
  updateTag('productos') // el stock ha cambiado
  redirect(`/pedidos/${respuesta.datos.pedidoId}`)
}

El número de filas viaja en un campo oculto, _filas, y vuelve en valores. El precio de esta técnica es un viaje al servidor por cada fila añadida; a cambio, el formulario es idéntico con y sin JavaScript. Si prefieres que con JavaScript sea instantáneo, añade además un onClick que haga e.preventDefault() y sume la fila en el cliente: es el ejercicio 9.

📘 Concepto: formNoValidate en los botones “Añadir línea” y “✕” le dice al navegador que no aplique la validación HTML (required, min…) para ese envío. Sin él, no podrías añadir una fila mientras otra tenga un dato incompleto.

9.3 El componente

// app/pedidos/nuevo/formulario-pedido.tsx
'use client'

import { useActionState, useState } from 'react'
import { crearPedido } from './acciones'
import { estadoInicial } from '@/lib/formulario'
import { ErrorCampo, ariaError, claseControl } from '@/components/error-campo'

type Props = {
  clientes: { clienteId: number; nombre: string }[]
  productos: { productoId: number; nombre: string; precio: number; stock: number }[]
}

const euros = new Intl.NumberFormat('es-ES', { style: 'currency', currency: 'EUR' })

export function FormularioPedido({ clientes, productos }: Props) {
  const [estado, accion, pendiente] = useActionState(crearPedido, estadoInicial)
  const [total, setTotal] = useState<number | null>(null)
  const e = estado.errores
  const v = estado.valores
  const filas = Number(v._filas) || 3

  // Mejora con JavaScript: total en vivo. Sin JS, el total lo calcula la API al guardar.
  function recalcular(ev: React.FormEvent<HTMLFormElement>) {
    const fd = new FormData(ev.currentTarget)
    let suma = 0
    for (let i = 0; i < filas; i++) {
      const p = productos.find((x) => x.productoId === Number(fd.get(`lineas.${i}.productoId`)))
      const cantidad = Number(fd.get(`lineas.${i}.cantidad`)) || 0
      const dto = Number(fd.get(`lineas.${i}.descuento`)) || 0
      if (p) suma += p.precio * cantidad * (1 - dto / 100)
    }
    setTotal(suma)
  }

  return (
    // key: tras cada respuesta el formulario se monta de nuevo con los valores devueltos
    <form key={JSON.stringify(v)} action={accion} onChange={recalcular} className="space-y-4">
      {estado.mensaje && <p role="alert" className="rounded bg-red-100 px-3 py-2 text-red-800">{estado.mensaje}</p>}
      <input type="hidden" name="_filas" value={filas} />

      <label className="block max-w-sm">Cliente
        <select name="clienteId" id="clienteId" required defaultValue={v.clienteId ?? ''} className={claseControl} {...ariaError('clienteId', e.clienteId)}>
          <option value="">Elige…</option>
          {clientes.map((c) => <option key={c.clienteId} value={c.clienteId}>{c.nombre}</option>)}
        </select>
        <ErrorCampo id="clienteId" errores={e.clienteId} />
      </label>

      <fieldset className="space-y-2">
        <legend className="font-semibold">Líneas</legend>
        <ErrorCampo id="lineas" errores={e.lineas} />
        {Array.from({ length: filas }, (_, i) => {
          const campo = (n: string) => `lineas.${i}.${n}`
          return (
            <div key={i} className="grid grid-cols-[1fr_6rem_6rem_auto] gap-2 items-start">
              <div>
                <select name={campo('productoId')} id={campo('productoId')} aria-label={`Producto de la línea ${i + 1}`}
                  defaultValue={v[campo('productoId')] ?? ''} className={claseControl} {...ariaError(campo('productoId'), e[campo('productoId')])}>
                  <option value="">Producto…</option>
                  {productos.map((p) => <option key={p.productoId} value={p.productoId}>{p.nombre} ({euros.format(p.precio)})</option>)}
                </select>
                <ErrorCampo id={campo('productoId')} errores={e[campo('productoId')]} />
              </div>
              <div>
                <input name={campo('cantidad')} id={campo('cantidad')} type="number" min="1" placeholder="Cant."
                  aria-label={`Cantidad de la línea ${i + 1}`} defaultValue={v[campo('cantidad')]}
                  className={claseControl} {...ariaError(campo('cantidad'), e[campo('cantidad')])} />
                <ErrorCampo id={campo('cantidad')} errores={e[campo('cantidad')]} />
              </div>
              <div>
                <input name={campo('descuento')} id={campo('descuento')} type="number" min="0" max="50" placeholder="% dto."
                  aria-label={`Descuento de la línea ${i + 1}`} defaultValue={v[campo('descuento')]}
                  className={claseControl} {...ariaError(campo('descuento'), e[campo('descuento')])} />
                <ErrorCampo id={campo('descuento')} errores={e[campo('descuento')]} />
              </div>
              <button name="intencion" value={`quitar-${i}`} formNoValidate disabled={filas === 1}
                className="mt-1 rounded border px-2 py-1.5 disabled:opacity-40" aria-label={`Quitar línea ${i + 1}`}>✕</button>
            </div>
          )
        })}
        <button name="intencion" value="añadir" formNoValidate className="rounded border px-3 py-1.5">+ Añadir línea</button>
      </fieldset>

      <p>{total === null ? 'El total se calcula al guardar.' : <>Total estimado: <strong>{euros.format(total)}</strong></>}</p>
      <button name="intencion" value="guardar" disabled={pendiente} className="rounded bg-indigo-700 px-4 py-2 text-white disabled:opacity-50">
        {pendiente ? 'Enviando…' : 'Crear pedido'}
      </button>
    </form>
  )
}

Cada fila de controles sin etiqueta visible lleva un aria-label (“Cantidad de la línea 2”), para que un lector de pantalla no lea tres veces “Cantidad”.

9.4 La prueba, sin JavaScript

Primero, lo que sí detecta el HTML. Con cantidad 0 en un campo con min="1", el navegador bloqueó el envío y no salió ninguna petición. Su mensaje (Chromium):

con cantidad 0, mensaje del navegador: Value must be greater than or equal to 1.

Luego, lo que el HTML no puede expresar: “la cantidad es obligatoria si has elegido producto, y al revés”. Añadimos una fila (de 3 a 4, conservando lo escrito), elegimos producto sin cantidad en la línea 2, cantidad sin producto en la 3 y repetimos el producto de la 1 en la 4:

filas tras añadir: 4 | conserva línea 0: 2
errores: [
  'lineas.1.cantidad-error: Al menos 1',
  'lineas.2.productoId-error: Elige un producto'
]
alerta: [ 'Revisa el pedido' ]

Formulario de nuevo pedido sin JavaScript con cuatro líneas; la segunda marca "Al menos 1" en la cantidad vacía y la tercera "Elige un producto"; arriba el aviso "Revisa el pedido"

Figura 9. Errores por fila calculados en el servidor y colocados en su fila exacta. Sin JavaScript, el total se calcula al guardar.

El producto repetido no se avisó todavía: como vimos, el refine de la lista espera a que las filas sean válidas. El mensaje “Al menos 1” para una cantidad vacía es la trampa de z.coerce del apartado 3.1 ("" se convierte en 0); el ejercicio 4 lo arregla.

Tras pulsar “✕” en la línea 4, las demás conservaron sus valores y se renumeraron; al completar la línea 2 y elegir producto en la 3, el pedido se creó y la página del pedido mostró la API calculando el total:

[red] <- 303  location=/pedidos/5026
Pedido 5026 / Talleres Martín S.L. · PENDIENTE /
2 × Portátil UltraBook 14 : 1798 € / 1 × Portátil Básico 15 (−10 %): 449.1 € / 2 × Torre Workstation : 3798 € /
Total: 6045.1 €

9.5 Con JavaScript: total en vivo y errores de negocio

Con JavaScript, el onChange del <form> (los eventos change suben desde cada control) recalcula el total estimado:

Formulario de pedido con JavaScript: dos líneas rellenas, Portátil Gaming 16 por 2 y Portátil UltraBook 14 por 1 con 10 % de descuento, y el texto "Total estimado: 3807,10 €"

Figura 10. El total en vivo es una mejora: sin JavaScript aparece “El total se calcula al guardar”.

Al subir la cantidad del Portátil Gaming 16 a 99 (el stock era 10), Zod lo aceptó (el máximo por línea es 99) y la API respondió con un 409:

alerta: No hay stock suficiente de Portátil Gaming 16
valores: clienteId=1002 | lineas.0.productoId=2 | lineas.0.cantidad=99 | ... | lineas.1.descuento=10 | ...

Un 409 no es un error de un campo, sino de la operación; por eso va al mensaje general. El formulario conservó todos los valores.

💡 Consejo: Fíjate en el reparto de reglas: el formato de cada dato (Zod en las dos capas), lo que depende de varias filas (Zod, refine de la lista) y lo que depende del estado del almacén (la API). Intentar comprobar el stock en el cliente sería engañoso: entre que lo miras y que guardas, otro pedido puede llevarse las unidades.


10. Otro caso práctico: departamentos con presupuesto opcional

El formulario de departamento es el más sencillo, y por eso es bueno para ver dos detalles que faltaban: un campo numérico opcional que debe guardarse como null (como el presupuesto de INVESTIGACIÓN) y un formulario que, tras guardar, no redirige, sino que se queda limpio para dar de alta otro.

// lib/esquemas.ts (continuación)
export const esquemaDepartamento = z.object({
  nombre: z.string().trim().min(1, 'El nombre es obligatorio').transform((s) => s.toUpperCase()),
  localidad: z.preprocess(vacioANull, z.string().trim().max(40).nullable()),
  presupuesto: z.preprocess(vacioANull, z.coerce.number({ error: 'Escribe un número' }).min(0, 'No puede ser negativo').nullable()),
})
// app/departamentos/nuevo/acciones.ts
'use server'

import { esquemaDepartamento } from '@/lib/esquemas'
import { escribir, type Departamento } from '@/lib/api'
import { erroresPorCampo, formDataAObjeto, valoresTextuales, type EstadoFormulario } from '@/lib/formulario'

export async function crearDepartamento(_previo: EstadoFormulario, formData: FormData): Promise<EstadoFormulario> {
  const valores = valoresTextuales(formData)
  const r = esquemaDepartamento.safeParse(formDataAObjeto(formData))
  if (!r.success) return { ok: false, mensaje: null, errores: erroresPorCampo(r.error), valores }

  const respuesta = await escribir<Departamento>('POST', '/departamentos', r.data)
  if (!respuesta.ok) {
    const errores = Object.fromEntries(Object.entries(respuesta.detalles ?? {}).map(([c, m]) => [c, [m]]))
    return { ok: false, mensaje: respuesta.detalles ? null : respuesta.mensaje, errores, valores }
  }
  const d = respuesta.datos
  const presupuesto = d.presupuesto === null ? 'sin presupuesto' : `${d.presupuesto.toLocaleString('es-ES')} €`
  // Éxito sin redirigir: devolvemos valores vacíos para que el formulario quede limpio
  return { ok: true, mensaje: `Creado ${d.nombre} (${d.deptId}), ${presupuesto}`, errores: {}, valores: {} }
}
// app/departamentos/nuevo/formulario-departamento.tsx
'use client'

import { useActionState } from 'react'
import { crearDepartamento } from './acciones'
import { estadoInicial } from '@/lib/formulario'
import { ErrorCampo, ariaError, claseControl } from '@/components/error-campo'

export function FormularioDepartamento() {
  const [estado, accion, pendiente] = useActionState(crearDepartamento, estadoInicial)
  const e = estado.errores
  const v = estado.valores

  return (
    <form action={accion} className="space-y-4 max-w-md">
      <p aria-live="polite" className={estado.ok ? 'text-green-800' : 'text-red-800'}>{estado.mensaje}</p>
      <label className="block">Nombre
        <input name="nombre" id="nombre" required defaultValue={v.nombre} className={claseControl} {...ariaError('nombre', e.nombre)} />
        <ErrorCampo id="nombre" errores={e.nombre} />
      </label>
      <label className="block">Localidad <span className="text-slate-500">(opcional)</span>
        <input name="localidad" id="localidad" defaultValue={v.localidad} className={claseControl} {...ariaError('localidad', e.localidad)} />
        <ErrorCampo id="localidad" errores={e.localidad} />
      </label>
      <label className="block">Presupuesto (€) <span className="text-slate-500">(opcional)</span>
        <input name="presupuesto" id="presupuesto" type="number" min="0" defaultValue={v.presupuesto} className={claseControl} {...ariaError('presupuesto', e.presupuesto)} />
        <ErrorCampo id="presupuesto" errores={e.presupuesto} />
      </label>
      <button disabled={pendiente} className="rounded bg-indigo-700 px-4 py-2 text-white disabled:opacity-50">
        {pendiente ? 'Creando…' : 'Crear departamento'}
      </button>
    </form>
  )
}

Resultado real: con el nombre ventas (la API lo compara sin mayúsculas ni tildes), el error llegó de la API y se colocó en el campo; con un nombre nuevo, Murcia como localidad y el presupuesto vacío:

errores: [ 'nombre-error: Ya existe un departamento con ese nombre' ]
mensaje: Creado CALIDAD 21822 (72), sin presupuesto | valores: nombre= | localidad= | presupuesto=

El presupuesto vacío llegó a la API como null, no como 0, y el formulario quedó vacío porque devolvimos valores: {} y React lo reseteó. El mensaje usa aria-live="polite" para que el lector de pantalla lo anuncie sin interrumpir.

Esta página no tiene datos de la API: es completamente estática (○ en el build) y funciona sin JavaScript sin hacer nada especial.


11. Errores frecuentes

Síntoma Causa Solución
invalid_type_error does not exist in type '$ZodStringParams' Ejemplo escrito para Zod 3 (incluida la guía de formularios de Next) z.string({ error: '...' })
Un campo opcional vacío se guarda como 0 z.coerce.number() convierte "" en 0 z.preprocess(vacioANull, z.coerce.number().nullable())
Una casilla desmarcada da true z.coerce.boolean() convierte "false" en true z.stringbool() con .optional() o .default(false)
Tras un error, el formulario aparece vacío React 19 resetea el formulario al terminar la acción Devolver valores y usarlos en defaultValue
Tras un error, los <select> vuelven a la primera opción El reset usa la opción selected del montaje inicial key={JSON.stringify(estado.valores)} en el <form>
Sin JavaScript se queda en “Cargando…” El formulario está dentro de un <Suspense> con datos dinámicos Datos con 'use cache' y formulario fuera de <Suspense>
El error de una fila aparece en otra Las filas vacías se eliminan antes de validar Convertirlas en null, validar y filtrar después
No sale el error de un refine del objeto Hay errores de tipo (invalid_type, invalid_value) en otros campos Es lo esperado: aparece al corregir los otros
Error: Body exceeded 1mb limit. (413) y el formulario no responde Límite por defecto de las Server Actions experimental.serverActions.bodySizeLimit y validar el tamaño al elegir el fichero
Se acepta un .php renombrado a .png .mime() mira file.type, que viene del navegador Comprobar los primeros bytes del fichero
Las claves $ACTION_... aparecen en tus datos Object.fromEntries(formData) sin filtrar Ignorar las claves que empiezan por $ACTION
La validación del cliente y la del servidor dicen cosas distintas Dos esquemas diferentes Un solo esquema en lib/, importado desde los dos lados

12. Ejercicios

Ejercicio 1: Del texto al tipo

Sin ejecutarlo, escribe qué devuelve safeParse en cada caso con el esquema esquemaEmpleado del apartado 3.2. Después compruébalo con un pequeño script (npx tsx scripts/prueba.ts).

  1. salario: ''
  2. comision: '' con oficio VENDEDOR
  3. telefono: '600 100 100'
  4. email: 'ANA@EMPRESA.ES'
  5. deptId: 'veinte'
Ver solución
  1. Error en salario: "" se convierte en 0 y no pasa .positive(). El mensaje es “El salario debe ser mayor que cero”, no “es obligatorio”: es la trampa de z.coerce. Si quieres el mensaje correcto, aplica vacioANull y deja que el null falle con su propio mensaje (lo hace el ejercicio 4 con las cantidades).
  2. Válido, comision: null: vacioANull lo convierte en null antes del coerce, y .nullable() lo acepta.
  3. Error en telefono: la expresión regular no admite espacios. Si quieres aceptarlos, añade .transform((s) => s.replace(/\s/g, '')) antes de .regex(), o hazlo en el preprocess.
  4. Válido, y sale ana@empresa.es gracias a .toLowerCase().
  5. Error en deptId: Number('veinte') es NaN, y Zod lo rechaza como invalid_type con su mensaje genérico, porque no le pusimos uno propio: Invalid input: expected number, received NaN. Como es un error de tipo, si además hubiera una comisión indebida, el refine de la comisión no se ejecutaría (apartado 3.3): lo comprobamos con comision: '50' y solo salió el error de deptId. En la práctica el usuario no puede escribir 'veinte' en un <select>, pero quien llame a la acción sin formulario sí; por eso conviene poner también ahí un mensaje propio.

Ejercicio 2: Formulario de departamento

Crea la página /departamentos/nuevo con nombre (obligatorio, se guarda en mayúsculas), localidad y presupuesto opcionales (vacío debe llegar a la API como null) y los errores 422 de la API colocados en su campo. Tras crear el departamento, el formulario debe quedar vacío y mostrar un mensaje con el identificador asignado.

Ver solución

Es el código completo del apartado 10. Las claves son:

  • presupuesto: z.preprocess(vacioANull, z.coerce.number(...).nullable()), para que el vacío sea null y no 0.
  • .transform((s) => s.toUpperCase()) en el nombre: los departamentos de la API están en mayúsculas.
  • La acción traduce respuesta.detalles a errores por campo; si no hay detalles (por ejemplo, un 401), muestra respuesta.mensaje.
  • En el éxito devuelve valores: {}. Como React resetea el formulario y los defaultValue pasan a ser undefined, los campos quedan vacíos.

Salida comprobada: Creado CALIDAD 21822 (72), sin presupuesto, y con el nombre ventas, “Ya existe un departamento con ese nombre” debajo del campo.

Ejercicio 3: El <select> que se vacía

Un compañero ha hecho un formulario de producto con un <select name="categoriaId"> y defaultValue={estado.valores.categoriaId}. Dice que “a veces” la categoría se pierde. Explica cuándo pasa exactamente y arréglalo.

Ver solución

Pasa siempre que la acción termina con el formulario ya montado (con JavaScript) y devuelve valores: React resetea el formulario, y el reset nativo devuelve cada <select> a la opción que tenía el atributo selected cuando se montó. React actualiza la selección actual en cada render, pero no esa opción “por defecto”. Los <input> no tienen el problema porque React sí actualiza su atributo value por defecto.

“A veces” es porque solo se nota tras un error del servidor (si la acción redirige, la página cambia) y no pasa sin JavaScript (cada respuesta es una página nueva).

Arreglo: forzar que el formulario se monte de nuevo con cada respuesta distinta:

<form key={JSON.stringify(estado.valores)} action={accion}>

Es lo que hace FormularioEmpleado. Comprobado con Playwright: sin la key, oficio= y deptId= tras el error de email; con ella, oficio=PROGRAMADOR y deptId=30.

Ejercicio 4: Cantidad vacía con mensaje correcto

En el pedido, una línea con producto y sin cantidad muestra “Al menos 1”. Cambia el esquema para que diga “Escribe la cantidad”, sin dejar de validar que sea un entero entre 1 y 99.

Ver solución

El problema es que z.coerce.number() convierte "" en 0. La primera idea suele ser pasar el vacío a null con vacioANull y dar un mensaje propio cuando la entrada sea null… pero no funciona: z.coerce.number() convierte también null en 0 (Number(null) es 0) antes de comprobar nada, así que lo probamos y seguía saliendo “Al menos 1”. La forma fiable es no usar coerce y convertir tú el texto:

const entero = (vacio: string) =>
  z.preprocess(
    (v) => (typeof v === 'string' && v.trim() === '' ? undefined : typeof v === 'string' ? Number(v) : v),
    z.number({ error: (issue) => (issue.input === undefined ? vacio : 'Escribe un número') }),
  )

// en esquemaLinea:
cantidad: entero('Escribe la cantidad').pipe(z.number().int('Sin decimales').min(1, 'Al menos 1').max(99, 'Como mucho 99')),

Resultados comprobados: "" da “Escribe la cantidad” (llega como undefined); "abc" da “Escribe un número” (Number("abc") es NaN); "0", “Al menos 1”; "2.5", “Sin decimales”; y "7", el número 7. Moraleja: z.coerce es cómodo, pero no distingue “vacío” de “cero”. En formularios, convertir tú el texto te da el control de los mensajes.

Ejercicio 5: Firma de los ficheros

Amplía tipoReal para aceptar también GIF (los ficheros GIF empiezan por el texto GIF87a o GIF89a) y explica por qué no basta con accept="image/*" en el <input>.

Ver solución
const EXTENSIONES = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp', 'image/gif': 'gif' } as const

function tipoReal(b: Uint8Array): keyof typeof EXTENSIONES | null {
  if (b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47) return 'image/png'
  if (b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return 'image/jpeg'
  const ascii = (i: number, j: number) => String.fromCharCode(...b.slice(i, j))
  if (ascii(0, 4) === 'RIFF' && ascii(8, 12) === 'WEBP') return 'image/webp'
  if (ascii(0, 6) === 'GIF87a' || ascii(0, 6) === 'GIF89a') return 'image/gif'
  return null
}

Añade también 'image/gif' a TIPOS_FOTO, para que .mime() lo acepte.

accept es solo una sugerencia para el diálogo de ficheros del navegador: filtra lo que se muestra, pero el usuario puede elegir “Todos los archivos”, y quien llame a la acción con curl ni siquiera tiene diálogo. Es de la misma familia que required: experiencia, no seguridad. Además, tanto accept como file.type se basan en la extensión; solo los bytes dicen lo que el fichero es de verdad.

Ejercicio 6: Rescatar un formulario de <Suspense>

Esta página funciona con JavaScript pero no sin él. Explica por qué y arréglala sin perder los datos de la API.

async function Formulario() {
  const departamentos = await leerDepartamentosSinCache()
  return <FormularioEmpleado accion={crearEmpleado} departamentos={departamentos} textoBoton="Dar de alta" />
}

export default function Pagina() {
  return (
    <Suspense fallback={<p>Cargando formulario…</p>}>
      <Formulario />
    </Suspense>
  )
}
Ver solución

Como los departamentos se piden sin caché, Next no puede incluirlos en el prerender: el <Suspense> hace que el formulario llegue en streaming, dentro de un <div hidden id="S:0">, y solo un script ($RC) lo hace visible. Sin JavaScript se queda oculto y el usuario ve “Cargando formulario…” para siempre. Lo comprobamos: el <input name="nombre"> estaba en el HTML, pero Playwright lo encontró “not visible”.

Arreglo: cachear la lectura y quitar el <Suspense>:

export async function leerDepartamentos() {
  'use cache'
  cacheLife('hours')
  cacheTag('departamentos')
  return (await leer<Lista<Departamento>>('/departamentos?tamano=100')).contenido
}
export default async function NuevoEmpleado() {
  const departamentos = await leerDepartamentos()
  return <FormularioEmpleado accion={crearEmpleado} departamentos={departamentos} textoBoton="Dar de alta" />
}

Resultado: /empleados/nuevo pasa a ○ en el build, 0 bloques hidden en el HTML y formulario visible sin JavaScript. Cuando se cree o renombre un departamento, la acción correspondiente debe llamar a updateTag('departamentos').

En Next.js 15 (sin cacheComponents) basta con quitar el <Suspense>: la página se renderiza entera en cada petición. Lo comprobamos en una página de edición con params y fetch sin caché: ƒ en el build, formulario visible sin JavaScript, error de validación con 200 y guardado con 303.

Ejercicio 7: Una regla entre campos

Añade al esquema del empleado esta regla: la fecha de alta no puede ser posterior a hoy. El error debe aparecer en el campo fechaAlta.

Ver solución

Como solo depende de un campo, no hace falta un refine de objeto: basta con uno en el propio campo, después del nullable:

fechaAlta: z.preprocess(
  vacioANull,
  z.iso.date({ error: 'Fecha no válida', abort: true })
    .refine((f) => f <= new Date().toISOString().slice(0, 10), 'No puede ser posterior a hoy')
    .nullable(),
),

Las fechas ISO (AAAA-MM-DD) se pueden comparar como texto porque el orden alfabético coincide con el cronológico. ¿Y ese abort: true? Sin él, con '2026-13-01' obtuvimos dos errores, “Fecha no válida” y “No puede ser posterior a hoy”: en Zod 4 un formato incorrecto no detiene las comprobaciones siguientes del mismo campo, porque el valor sigue siendo un texto. Con abort: true, la primera comprobación que falla corta la cadena y solo sale “Fecha no válida”. Resultados comprobados: '' da null, '2020-01-01' es válida y '2999-01-01' da “No puede ser posterior a hoy”.

Ojo: “hoy” en el servidor y “hoy” en el navegador pueden no coincidir cerca de la medianoche si están en husos horarios distintos (toISOString usa UTC). Si eso importa en tu aplicación, decide un huso de referencia y úsalo en los dos lados.

Ejercicio 8: Validar un campo al salir de él (propuesto)

Muestra el error de cada campo del formulario de empleado en cuanto el usuario sale de él (evento blur), sin esperar al envío. Pista: en Zod 4, .refine() sobre un objeto devuelve el mismo objeto, así que esquemaEmpleado.shape sigue existiendo (lo comprobamos: tiene los 9 campos) y puedes validar uno solo con esquemaEmpleado.shape.telefono.safeParse(valor). ¿Qué pasa con la regla de la comisión, que depende de dos campos y no está en ningún shape?

Ejercicio 9: Añadir filas sin ir al servidor (propuesto)

Mantén el botón “Añadir línea” con name="intencion" value="añadir" para que siga funcionando sin JavaScript, pero añádele un onClick que, cuando hay JavaScript, cancele el envío y sume la fila en el cliente. Piensa dónde vive ahora el número de filas: ¿en el estado de la acción, en un useState o en los dos? ¿Qué pasa con los valores ya escritos al añadir una fila?

Ejercicio 10: Cambiar el estado de un pedido (propuesto)

En la página del pedido, añade un formulario con un <select> de estados (PENDIENTE, ENVIADO, ENTREGADO, CANCELADO) que llame a PATCH /api/pedidos/:id. Valida con z.enum y comprueba que funciona sin JavaScript. Como la página del pedido usa 'use cache' con la etiqueta pedido-<id>, ¿qué tienes que llamar en la acción para que, tras el redirect, se vea el estado nuevo?

Ejercicio 11: Casilla “descatalogado” (propuesto)

Crea un formulario de edición de producto con una casilla descatalogado. Usa z.stringbool() y comprueba los tres casos: marcada, desmarcada (el campo no viaja en el FormData) y un valor manipulado como "quizás". ¿Qué devuelve cada uno? ¿Qué mensaje verá el usuario en el tercer caso y cómo lo cambiarías?


Resumen

Concepto API Idea clave
Esquema z.object, z.infer, z.input Un esquema da el validador y el tipo
Validar sin excepciones safeParse Un dato no válido es un caso esperado, no un error
Mensajes en Zod 4 { error: '...' }, .min(1, '...') invalid_type_error y required_error son de Zod 3
Formatos z.email(), z.iso.date(), z.enum() Funciones de primer nivel en Zod 4
Texto a número z.coerce.number() Convierte "" en 0: cuidado con los opcionales
Opcionales z.preprocess(vacioANull, ... .nullable()) Vacío pasa a null antes de validar
Casillas z.stringbool() No uses z.coerce.boolean()
Reglas entre campos .refine(fn, { path }) No se ejecuta si hay errores de tipo
Errores z.flattenError, z.treeifyError, issue.path path.join('.') sirve para planos y anidados
Estado de la acción { ok, mensaje, errores, valores } Devuelve siempre lo que escribió el usuario
Reset de React 19 defaultValue + key en el <form> Sin key, los <select> vuelven a la primera opción
Errores de la API 422 detalles Se traducen a errores por campo
Accesibilidad aria-invalid, aria-describedby, autoFocus El foco va al primer error, también sin JS
Validación en cliente onSubmit + preventDefault Mismo esquema; si falla, no se llama a la acción
Mejora progresiva <form action={accion}> Sin JS: POST normal, 200 HTML o 303
Formularios y streaming 'use cache' sin <Suspense> 🆕 16.0 Lo que llega en streaming no se ve sin JS
Leer lo escrito updateTag 🆕 16.0, redirect En Next.js 15, revalidatePath
Ficheros z.file().max().mime() file.type lo dice el navegador: mira los bytes
Límite de tamaño experimental.serverActions.bodySizeLimit 1 MB por defecto; si se supera, no hay estado que devolver
Formularios complejos name="lineas.0.cantidad", <button name value> Botones de envío con intención funcionan sin JS