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 dez.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,redirecty la revalidación. Aquí no lo repetimos: nos centramos en qué datos entran, cómo se validan y qué se le devuelve al usuario. SiuseActionStateno 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:
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úmero2450.5y""ennull. 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_erroroz.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()); entoncesz.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:
- 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”. - La coma decimal no vale.
Number("12,5")esNaN. Un<input type="number">siempre envía punto, pero un<input type="text">puede llegar con coma si el usuario es español. z.coerce.boolean()no sirve para casillas. Convierte cualquier texto no vacío entrue, incluido"false". Para casillas y selects de sí/no usaz.stringbool(), que entiende"on","true","1","yes"y sus contrarios. Ojo: una casilla sin marcar no aparece en elFormData, 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). Unrequiredde HTML no detecta eso: tres espacios cuentan como “relleno”.z.email(...).toLowerCase()valida y normaliza en un paso. AsíMarta.Ruiz@Empresa.esymarta.ruiz@empresa.esno acaban siendo dos usuarios.z.enum(OFICIOS)impide que alguien envíe"ASTRONAUTA"modificando el<select>con las DevTools..refineen el objeto expresa una regla entre campos, ypath: ['comision']dice a qué campo se le cuelga el error.z.inferda el tipo de salida (números,null), yz.inputel de entrada (lo que aceptas). Conpreprocessycoerce, la entrada es casi todounknown.
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
whenenrefine, 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: {} }
errorestiene una lista de mensajes por campo. La clave es elnamedel control:'email', o'lineas.2.cantidad'en un formulario con filas.valoresguarda lo que escribió el usuario, tal cual, como texto. Es imprescindible, como verás en el apartado 4.4.mensajees 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 mismoname) y no entiende anidamientos.formDataAObjetoes una versión mínima que cubre lo que usamos en el curso. Si necesitas valores repetidos, usaformData.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 defineAPI_URL=http://localhost:3000/apien.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.

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
empIdvenga 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.

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 leerprocess.envcon 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:
- Chrome DevTools: abre las DevTools, pulsa
Ctrl+Shift+P, escribeDisable JavaScripty recarga. Se mantiene mientras las DevTools estén abiertas. - Firefox:
about:config,javascript.enabledafalse. - 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 |
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

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,cacheTagyupdateTagno existen de forma estable en Next.js 15. Si usas Next.js 15: no rodees el formulario con<Suspense>ni pongas unloading.tsxen su ruta; la página se renderiza entera en cada petición (ƒ), el HTML llega completo y funciona sin JavaScript. Tras guardar, usarevalidatePathen lugar deupdateTag.
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
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 (paramssingenerateStaticParams,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
generateStaticParamsun solo identificador de ejemplo (lo exigecacheComponents), 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.tsxsobrescribirí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
esquemaFotofunciona en el navegador, porqueFileexiste 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.createObjectURLcrea una URL temporal que apunta al fichero en memoria, sin subirlo. Hay que liberarla conrevokeObjectURL. - Nombre en el botón.
useFormStatus()devuelve, además depending, eldata(FormData) del envío en curso. Lo usamos para escribir “Subiendo avatar.png…”.

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

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 existedefaultValuepara 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:
formNoValidateen 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' ]

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:

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,
refinede 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).
salario: ''comision: ''con oficioVENDEDORtelefono: '600 100 100'email: 'ANA@EMPRESA.ES'deptId: 'veinte'
Ver solución
- Error en
salario:""se convierte en0y no pasa.positive(). El mensaje es “El salario debe ser mayor que cero”, no “es obligatorio”: es la trampa dez.coerce. Si quieres el mensaje correcto, aplicavacioANully deja que elnullfalle con su propio mensaje (lo hace el ejercicio 4 con las cantidades). - Válido,
comision: null:vacioANulllo convierte ennullantes delcoerce, y.nullable()lo acepta. - 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 elpreprocess. - Válido, y sale
ana@empresa.esgracias a.toLowerCase(). - Error en
deptId:Number('veinte')esNaN, y Zod lo rechaza comoinvalid_typecon 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, elrefinede la comisión no se ejecutaría (apartado 3.3): lo comprobamos concomision: '50'y solo salió el error dedeptId. 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 seanully no0..transform((s) => s.toUpperCase())en el nombre: los departamentos de la API están en mayúsculas.- La acción traduce
respuesta.detallesa errores por campo; si no haydetalles(por ejemplo, un 401), muestrarespuesta.mensaje. - En el éxito devuelve
valores: {}. Como React resetea el formulario y losdefaultValuepasan a serundefined, 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 |