Módulo 10: Server Actions y mutaciones
Objetivos del módulo
- Entender qué es una Server Action (y una Server Function), cómo la convierte Next.js en una petición
POSTy qué viaja de verdad por la red - Escribir acciones con
'use server', en un fichero propio o dentro de un Server Component, y reconocer los errores que da Next cuando rompes sus reglas - Enviar formularios a una acción, leer el
FormDatay pasar argumentos extra conbind - Mantener la pantalla al día después de una mutación con
updateTag,revalidateTag,revalidatePathyrefresh, sabiendo cuál elegir - Redirigir al terminar con
redirect, y evitar la trampa de llamarlo dentro de untry - Devolver resultados al formulario con
useActionState, mostrar el estado de envío conuseFormStatusy adelantar el resultado conuseOptimistic - Llamar acciones desde manejadores de eventos con
startTransition, y saber por qué se ejecutan una detrás de otra - Distinguir errores esperados (se devuelven) de errores inesperados (se lanzan y los recoge
error.tsx) - Conocer lo que protege Next por ti (origen, tamaño, cifrado de cierres, eliminación de acciones sin usar) y lo que tienes que proteger tú en cada acción
- Construir la gestión de departamentos y pedidos de EMPRESA escribiendo de verdad en la API
💡 Consejo: Las Actions son una función de React, no de Next. El Curso de React explica
useActionState,useFormStatus,useOptimisticy las transiciones en una aplicación de cliente. Aquí damos esa base por sabida y nos centramos en lo que pone Next.js: la directiva'use server', cómo se llama a una función que vive en el servidor, cómo se refresca la caché en la misma respuesta y qué pasa con la seguridad.
1. Qué es una Server Action
Hasta ahora, en este curso, los datos solo iban en un sentido: del servidor a la pantalla. Los Server Components leían la API EMPRESA, Next guardaba el resultado en caché (módulo 09) y el navegador recibía el HTML. Pero una aplicación de gestión también escribe: crear un departamento, cambiar el estado de un pedido, borrar un empleado. A estas operaciones se les llama mutaciones.
En una aplicación React clásica (React + Vite, por ejemplo) una mutación exige tres piezas:
- Un endpoint en un servidor aparte (
POST /api/departamentos). - Un
fetchen el navegador que lo llama, con suJSON.stringify, sus cabeceras y su gestión de errores. - Algo que vuelva a pedir los datos para que la lista se actualice.
Next.js reduce las tres piezas a una: una función asíncrona que se ejecuta en el servidor y que puedes llamar desde el navegador como si fuera local. Eso es una Server Function. Cuando la usas para enviar un formulario o hacer una mutación, se le llama Server Action.
// app/departamentos/acciones.ts
'use server'
export async function crearDepartamento(formData: FormData) {
const nombre = formData.get('nombre')
// ... guardar en la API o en la base de datos ...
}
// app/departamentos/nuevo/page.tsx (Server Component)
import { crearDepartamento } from '../acciones'
export default function NuevoDepartamento() {
return (
<form action={crearDepartamento}>
<input name="nombre" />
<button>Crear</button>
</form>
)
}
No hay endpoint que declarar, ni fetch que escribir, ni JSON.stringify. Pasas la función al atributo action del formulario, y al pulsar el botón React la llama en el servidor con los datos del formulario.
📘 Concepto: Server Function es el término general de React: una función asíncrona marcada con
'use server'que el cliente puede invocar por la red. Server Action es una Server Function usada para una mutación, normalmente desde un<form action>, un<button formAction>o dentro destartTransition. En la práctica, todo el mundo dice “Server Action”, y es lo que haremos en este módulo.
1.1. Cómo es posible llamar a una función del servidor
La magia la hace el compilador. Cuando Turbopack encuentra 'use server':
- En el paquete del servidor deja la función tal cual y la registra con un identificador (un hash de 42 caracteres hexadecimales).
- En el paquete del navegador sustituye la función por una referencia: un objeto que solo contiene ese identificador y un pequeño despachador que sabe hacer
POSTal servidor.
Cuando llamas a la referencia, el despachador envía una petición POST a la URL de la página actual, con una cabecera Next-Action que lleva el identificador y un cuerpo con los argumentos serializados. El servidor busca la función por su identificador, la ejecuta y devuelve el resultado.
Figura 1. El ciclo completo de una Server Action. El código de la función no sale nunca del servidor: al navegador solo llega su identificador.
Lo comprobamos con el laboratorio. Este es el POST real que hace el formulario de alta de departamentos que construiremos en el apartado 6, capturado con Playwright:
POST http://127.0.0.1:5510/departamentos/nuevo
next-action: 601fa887a6e008be5ed883c89b31e291810e85e151
accept: text/x-component
content-type: multipart/form-data; boundary=----WebKitFormBoundaryl5KupA4ASq0XFu3d
------WebKitFormBoundaryl5KupA4ASq0XFu3d
Content-Disposition: form-data; name="_1_$ACTION_REF_1"
------WebKitFormBoundaryl5KupA4ASq0XFu3d
Content-Disposition: form-data; name="_1_$ACTION_1:0"
{"id":"601fa887a6e008be5ed883c89b31e291810e85e151","bound":"$@1"}
...
Y este es el de un botón que llama a una acción con dos argumentos normales (cambiarEstado(5024, 'ENVIADO')), sin formulario:
POST http://127.0.0.1:5510/pedidos
next-action: 60d2693886b967c783dcd8f3073469192a7281f85e
accept: text/x-component
content-type: text/plain;charset=UTF-8
[5024,"ENVIADO"]
Fíjate en cuatro cosas:
| Detalle | Qué significa |
|---|---|
Método POST a la URL de la página | No hay una ruta /api/.... Las acciones solo se invocan con POST; un GET nunca ejecuta una acción. |
Cabecera next-action | El identificador de la función. Es lo único que el navegador sabe de ella. |
| Cuerpo | Con un formulario, multipart/form-data con los campos; con argumentos sueltos, un array JSON con los argumentos. |
accept: text/x-component | El navegador espera una respuesta en el formato de los Server Components (Flight), no JSON. |
La respuesta tiene este aspecto (recortada). La primera línea es el árbol de la página renderizado de nuevo; la segunda, el valor que devolvió la función:
HTTP/1.1 200 OK
x-action-revalidated: 1
Content-Type: text/x-component
0:{"a":"$@1","q":"","i":false,"t":{"t":{"s":"","h":49234,"d":{"r":["$","$2","c",{"children":[...
1:{"ok":true,"mensaje":"Pedido 5023: CANCELADO"}
La cabecera x-action-revalidated: 1 y la propiedad t aparecen porque esa acción invalidó la caché: Next aprovechó la misma respuesta para enviar la página actualizada. Cuando la acción no toca la caché, la respuesta solo lleva el valor:
0:{"a":"$@1","q":"","i":false,"b":"8SoEfvX3tXmnU8kkgPzpS"}
1:{"ok":false,"mensaje":"Datos no válidos"}
⚠️ Importante: Como ves, una Server Action es un endpoint público. Cualquiera que conozca el identificador puede enviar ese
POSTconcurl, sin pasar por tu formulario y con los argumentos que quiera. Lo demostraremos en el apartado 12. Trata cada acción como tratarías una ruta de una API: comprueba quién llama y valida lo que recibes.
1.2. Server Actions frente a Route Handlers
Next.js tiene otra forma de recibir peticiones en el servidor: los Route Handlers (route.ts), que verás en el módulo 12. ¿Cuándo usar cada uno?
| Server Action | Route Handler | |
|---|---|---|
| Quién la llama | Tu propia interfaz de React | Cualquiera: otra aplicación, un móvil, un webhook |
| URL | No eliges ninguna: la de la página, con un identificador opaco | La que tú defines (/api/departamentos) |
| Métodos | Solo POST | GET, POST, PUT, DELETE… |
| Formato | Lo decide React (Flight) | El que tú quieras (JSON, CSV, imágenes…) |
| Refresco de la interfaz | Integrado: devuelve la página actualizada en la misma respuesta | Tienes que hacerlo tú |
| Mejora progresiva (funciona sin JavaScript) | Sí, con <form action> | No |
Regla práctica: para mutaciones que dispara tu propia interfaz, Server Actions; para una API que consumen otros, Route Handlers. Y para leer datos, ninguno de los dos: un Server Component que lee directamente (módulo 08).
2. La directiva 'use server'
Hay dos formas de declarar una acción.
2.1. En un fichero propio
'use server' en la primera línea del fichero convierte todas sus exportaciones en Server Functions. Es la forma recomendada: las acciones quedan agrupadas, se pueden importar desde Server Components y desde Client Components, y se prueban fácilmente.
// app/departamentos/acciones.ts
'use server'
import { updateTag } from 'next/cache'
import { escribirEnApi } from '@/lib/api'
export async function crearRapido(formData: FormData) {
// ...
}
export async function borrarDepartamento(deptId: number) {
// ...
}
Las reglas son estrictas, y Next las comprueba al compilar:
- La directiva va antes de cualquier
import(solo puede haber comentarios delante). - Solo se pueden exportar funciones asíncronas. Ni constantes, ni tipos en tiempo de ejecución, ni objetos, ni funciones síncronas.
Si exportas una constante, el build falla. Este es el mensaje real de Next.js 16.4 con un fichero que exporta LIMITE:
./app/demo/malo/acciones.ts:3:1
Error: Only async functions are allowed to be exported in a "use server" file.
1 | 'use server'
2 |
> 3 | export const LIMITE = 5
| ^^^^^^^^^^^^^^^^^^^^^^^
Los tipos de TypeScript sí se pueden exportar (export type EstadoFormulario = ...), porque desaparecen al compilar. Las constantes compartidas, en cambio, van en otro fichero sin directiva (lib/constantes.ts).
💡 Consejo: Un fichero
'use server'no es un fichero “que solo se ejecuta en el servidor”. Es un fichero cuyas funciones exportadas se pueden llamar desde fuera. Cualquier función auxiliar exportada desde él se convierte en un endpoint. Las funciones internas (sinexport) no lo son. Para marcar un módulo como “solo servidor, nunca llamable”, usaimport 'server-only'(módulo 05).
2.2. Dentro de un Server Component
Puedes declarar la acción dentro del propio componente poniendo 'use server' en la primera línea del cuerpo de la función:
// app/demo/error/page.tsx
export default function DemoError() {
async function fallar() {
'use server'
throw new Error('Fallo interno: la tabla PEDIDOS está bloqueada')
}
return (
<form action={fallar}>
<button>Ejecutar</button>
</form>
)
}
Es cómodo para acciones pequeñas que solo usa esa página, y tiene una propiedad especial: la función puede leer variables del componente (es un cierre, closure). Lo veremos con detalle en el apartado 11, porque esas variables viajan al navegador y vuelven.
Lo que no puedes hacer es declarar una acción en línea dentro de un Client Component. El código de un Client Component se envía al navegador, y una función que se ejecuta en el servidor no puede vivir ahí. Next lo detecta al compilar:
./app/demo/malo/page.tsx:5:5
Error: It is not allowed to define inline "use server" annotated Server Actions in Client Components.
To use Server Actions in a Client Component, you can either export them from a separate file
with "use server" at the top, or pass them down through props from a Server Component.
El propio mensaje te da las dos soluciones: importar la acción desde un fichero 'use server', o recibirla como prop desde un Server Component.
2.3. Pasar una acción como prop
Una referencia a una Server Action es uno de los pocos tipos de función que sí se pueden serializar al cruzar la frontera servidor-cliente (recuerda la tabla de props del módulo 05). Por eso un Server Component puede pasarla a un Client Component:
// Server Component
import { crearDepartamento } from '../acciones'
import { FormularioDepartamento } from '../formulario-departamento'
export default function NuevoDepartamento() {
return <FormularioDepartamento accion={crearDepartamento} />
}
// Client Component
'use client'
type Accion = (anterior: EstadoFormulario, formData: FormData) => Promise<EstadoFormulario>
export function FormularioDepartamento({ accion }: { accion: Accion }) {
// ...
}
Esto permite tener un solo formulario (cliente, con estado) que sirve para crear y para editar, y que sea la página (servidor) la que decide qué acción usa. Es exactamente lo que haremos en el caso práctico.
3. Preparar el laboratorio: escribir en la API EMPRESA
Las acciones de este módulo escriben en la API EMPRESA. Las lecturas son públicas, pero las escrituras exigen un token: POST /api/auth/login con un usuario y una contraseña devuelve un JWT que hay que enviar en la cabecera Authorization: Bearer ....
La autenticación de usuarios llega en el módulo 17. De momento, la aplicación usará una cuenta de servicio: el servidor de Next inicia sesión en la API con unas credenciales guardadas en variables de entorno, y reutiliza el token mientras sea válido.
Arranca la API y Next en puertos distintos, como en los módulos anteriores:
# Terminal 1: la API EMPRESA (recursos/empresa-api.zip)
node server.mjs
# API en http://localhost:3000/api
# Terminal 2: la aplicación Next en otro puerto
npm run dev -- -p 3001
Crea .env.local en la raíz del proyecto Next:
API_URL=http://localhost:3000/api
API_EMAIL=ana.garcia@empresa.es
API_PASSWORD=secreto
Y la capa de acceso a la API, que solo puede importarse desde el servidor:
// lib/api.ts
import 'server-only'
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> }
// Token de servicio: la aplicación inicia sesión en la API con una cuenta propia.
// En el módulo 17 lo sustituiremos por la sesión del usuario que ha entrado.
let tokenGuardado: { token: string; caduca: number } | null = null
async function obtenerToken(): Promise<string> {
if (tokenGuardado && tokenGuardado.caduca > Date.now() + 60_000) {
return tokenGuardado.token
}
const respuesta = await fetch(`${API}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: process.env.API_EMAIL,
password: process.env.API_PASSWORD,
}),
})
if (!respuesta.ok) throw new Error(`Login en la API fallido (${respuesta.status})`)
const { token } = await respuesta.json()
tokenGuardado = { token, caduca: Date.now() + 55 * 60_000 }
return token
}
export async function escribirEnApi<T>(
ruta: string,
metodo: 'POST' | 'PUT' | 'PATCH' | 'DELETE',
cuerpo?: unknown,
): Promise<RespuestaApi<T>> {
const token = await obtenerToken()
const respuesta = await fetch(`${API}${ruta}`, {
method: metodo,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: cuerpo === undefined ? undefined : JSON.stringify(cuerpo),
})
const datos = respuesta.status === 204 ? null : await respuesta.json()
if (!respuesta.ok) {
return { ok: false, status: respuesta.status, mensaje: datos.mensaje, detalles: datos.detalles }
}
return { ok: true, status: respuesta.status, datos: datos as T }
}
export async function leerDeApi<T>(ruta: string): Promise<T> {
const respuesta = await fetch(`${API}${ruta}`)
if (!respuesta.ok) throw new Error(`La API respondió ${respuesta.status} en ${ruta}`)
return respuesta.json()
}
Dos decisiones de diseño que conviene entender:
escribirEnApino lanza cuando la API responde con un error (401, 409, 422…): devuelve{ ok: false, ... }. Así las acciones pueden convertir esos errores en mensajes para el usuario. Un fallo de red, en cambio, sí lanza: es un error inesperado (apartado 10).- Las credenciales y el token viven en una variable de módulo del servidor. Nunca llegan al navegador:
lib/api.tsllevaserver-only, y si algún Client Component lo importara por error, el build fallaría.
Las lecturas, como en el módulo 09, se guardan en caché con una etiqueta que las acciones podrán invalidar:
3.1. La capa de datos con etiquetas 🔄 16.0
🔄 Cambia en Next.js 16.0. En Next.js 15,
'use cache'ycacheTagsolo existen como experimentales (experimental.useCacheyunstable_cacheTag). Si usas Next.js 15: etiqueta las lecturas con la opción defetchnext: { tags: ['departamentos'] }(ycache: 'force-cache'), e invalídalas en la acción conrevalidateTag('departamentos'). Está comprobado con Next.js 15.5.28 (ejercicio 8).
// lib/datos.ts
import 'server-only'
import { cacheLife, cacheTag } from 'next/cache'
import { leerDeApi } from './api'
export type Departamento = {
deptId: number
nombre: string
localidad: string | null
presupuesto: number | null
}
export type Pedido = {
pedidoId: number
cliente: string
estado: 'PENDIENTE' | 'ENVIADO' | 'ENTREGADO' | 'CANCELADO'
total: number
}
type Lista<T> = { contenido: T[]; total: number }
export async function obtenerDepartamentos(): Promise<Departamento[]> {
'use cache'
cacheTag('departamentos')
cacheLife('hours')
const { contenido } = await leerDeApi<Lista<Departamento>>('/departamentos?tamano=100&orden=deptId')
return contenido
}
export async function obtenerDepartamento(id: number): Promise<Departamento | null> {
'use cache'
cacheTag('departamentos', `departamento-${id}`)
cacheLife('hours')
const todos = await obtenerDepartamentos()
return todos.find((d) => d.deptId === id) ?? null
}
export async function obtenerPedidosPendientes(): Promise<Pedido[]> {
'use cache'
cacheTag('pedidos')
cacheLife('hours')
const { contenido } = await leerDeApi<Lista<Pedido>>('/pedidos?tamano=6&orden=-pedidoId')
return contenido.map(({ pedidoId, cliente, estado, total }) => ({ pedidoId, cliente, estado, total }))
}
Con esto, la lista de departamentos se calcula una vez y se sirve desde la caché durante horas. El build la marca como ◐ (Partial Prerender) con Revalidate 1h. ¿Cómo hacemos que el departamento que acabamos de crear aparezca ya, y no dentro de una hora? Esa es la pregunta central de las mutaciones, y la responde el apartado 5.
⚠️ Importante: Como el build prerenderiza la lista de departamentos,
next buildnecesita la API en marcha. Si está parada, la compilación falla al intentar leerla.
4. Formularios con Server Actions
4.1. La acción mínima
El caso más simple es un formulario dentro de un Server Component. La página de departamentos tiene, debajo de la tabla, un alta rápida:
// app/departamentos/page.tsx (fragmento)
import { crearRapido } from './acciones'
// ...
<form action={crearRapido} id="alta-rapida">
<input name="nombre" placeholder="Nombre del departamento" />
<input name="localidad" placeholder="Localidad" />
<button>Crear</button>
</form>
// app/departamentos/acciones.ts (fragmento)
'use server'
import { updateTag } from 'next/cache'
import { escribirEnApi } from '@/lib/api'
function leerCampos(formData: FormData) {
return {
nombre: String(formData.get('nombre') ?? '').trim(),
localidad: String(formData.get('localidad') ?? '').trim(),
presupuesto: String(formData.get('presupuesto') ?? '').trim(),
}
}
function aCuerpo(valores: ReturnType<typeof leerCampos>) {
return {
nombre: valores.nombre,
localidad: valores.localidad || null,
presupuesto: valores.presupuesto === '' ? null : Number(valores.presupuesto),
}
}
export async function crearRapido(formData: FormData) {
const valores = leerCampos(formData)
if (!valores.nombre) return // sin estado que devolver: el formulario no puede mostrar nada
await escribirEnApi('/departamentos', 'POST', aCuerpo(valores))
updateTag('departamentos')
}
Cuando un formulario llama a una acción, React le pasa automáticamente un objeto FormData con los campos que tienen name. Algunas cosas que conviene saber de FormData:
| Método | Devuelve | Ojo con |
|---|---|---|
formData.get('nombre') | FormDataEntryValue \| null (es decir, string \| File \| null) | No es string: conviértelo con String(...) y valida |
formData.getAll('etiquetas') | Un array | Para casillas con el mismo name y <select multiple> |
formData.has('acepta') | boolean | Una casilla sin marcar no se envía |
Object.fromEntries(formData) | Un objeto plano | Incluye campos internos de React que empiezan por $ACTION_ |
Y los detalles importantes de la acción:
- Todo llega como texto. Un
<input type="number">envía"120000", no120000. Convierte tú los tipos. leerCamposyaCuerpono se exportan: son funciones internas del fichero y no se convierten en endpoints.updateTag('departamentos')invalida la caché de la lista. Lo explica el apartado 5.- La función no devuelve nada. Si el nombre está vacío, simplemente no hace nada, y el usuario no se entera. Para devolver mensajes al formulario necesitas
useActionState(apartado 6).
4.2. Mejora progresiva: sin JavaScript también funciona
Un <form action={accion}> renderizado en el servidor funciona aunque el JavaScript no haya cargado (o esté desactivado). Mira el HTML que genera Next para ese formulario:
<form id="alta-rapida" action="" encType="multipart/form-data" method="POST">
<input type="hidden" name="$ACTION_ID_40a3cdfb22679a047ec4d720a0fabc540bd5b37587"/>
<input placeholder="Nombre del departamento" name="nombre"/>
...
</form>
Es un formulario HTML normal: method="POST" a la misma URL, con un campo oculto $ACTION_ID_... que identifica la acción. Si el navegador no tiene JavaScript, lo envía como cualquier formulario de toda la vida y Next ejecuta la acción igual. Cuando el JavaScript ya ha cargado, React intercepta el envío y lo hace con fetch, sin recargar la página. A esto se le llama mejora progresiva: la página funciona con lo mínimo y mejora cuando hay más.
📘 Concepto: Mejora progresiva (progressive enhancement) importa más de lo que parece. En una conexión móvil lenta, el HTML llega mucho antes que el JavaScript. Si el usuario pulsa “Crear” en ese intervalo, un formulario con
onSubmitno hace nada; uno conactionse envía igual. En los Client Components, React además pone en cola los envíos hechos antes de la hidratación y prioriza hidratar ese formulario.
4.3. Post/Redirect/Get: termina con redirect
Probamos el alta rápida en un navegador con el JavaScript desactivado (Playwright con javaScriptEnabled: false). La acción se ejecutó y el departamento se creó, pero en Next.js 16.4.3 con cacheComponents, en una ruta ◐ (Partial Prerender) como /departamentos, la respuesta al POST sin JavaScript fue una página en blanco: el servidor devolvió solo la parte dinámica del documento, la que el navegador necesita JavaScript para recolocar.
La solución es un patrón clásico de la web, anterior a cualquier framework: Post/Redirect/Get (PRG). Después de procesar un POST, el servidor no responde con una página, sino con una redirección, y el navegador pide la página de destino con un GET normal. Además de arreglar este caso, PRG evita el aviso de “¿reenviar el formulario?” al recargar.
En una Server Action, PRG es una línea: redirect al terminar.
// app/departamentos/acciones.ts
import { redirect } from 'next/navigation'
export async function crearRapidoPRG(formData: FormData) {
await crearRapido(formData)
redirect('/departamentos')
}
<button formAction={crearRapidoPRG} id="crear-prg">Crear (PRG)</button>
Resultado real sin JavaScript:
SINJS POST http://127.0.0.1:5510/departamentos 303 /departamentos
PRG url final http://127.0.0.1:5510/departamentos | visible: true
El servidor respondió 303 See Other, el navegador hizo GET /departamentos y la tabla muestra el nuevo departamento:

Figura 2. Con el JavaScript desactivado, la acción con redirect responde con un 303 y el navegador carga la lista actualizada con un GET.
¿Por qué 303 y no el 307 que usa redirect en un Server Component? Porque 307 conserva el método: el navegador repetiría el POST en la URL de destino. 303 obliga a seguirla con GET. Next elige el código correcto según el contexto:
Dónde llamas a redirect | Qué responde Next |
|---|---|
| Server Component o Route Handler | 307 Temporary Redirect |
| Server Action, envío sin JavaScript | 303 See Other |
| Server Action, con JavaScript | No hay código HTTP de redirección: el router del cliente navega (por defecto con push, añadiendo una entrada al historial) |
💡 Consejo: Si tu aplicación debe funcionar sin JavaScript (formularios públicos, accesibilidad, conexiones muy lentas), termina las acciones que se usan con
<form action>conredirect. El formulario de/departamentos/nuevo, que es una ruta estática (○), sí devolvió la página completa con el mensaje de error sin JavaScript; el problema solo apareció en la ruta con partes dinámicas. Pruébalo siempre en tu caso.
4.4. Varios botones, varias acciones: formAction
En el ejemplo anterior el formulario tiene dos botones, y cada uno ejecuta una acción distinta. El atributo formAction de un <button type="submit"> (o de un <input type="submit">) sustituye a la action del formulario para ese botón:
<form action={guardarBorrador}>
<textarea name="texto" />
<button>Guardar borrador</button>
<button formAction={publicar}>Publicar</button>
</form>
Las dos acciones reciben el mismo FormData. Es la forma limpia de resolver “guardar” y “guardar y salir”, o “aprobar” y “rechazar”, sin campos ocultos que digan qué botón se pulsó.
4.5. Argumentos extra con bind
A menudo la acción necesita un dato que no está en el formulario: el identificador del departamento que se edita, por ejemplo. Hay dos formas de pasarlo:
Con bind, el método de JavaScript que crea una función con algunos argumentos ya fijados:
// acciones.ts
export async function guardarDepartamento(deptId: number, _anterior: EstadoFormulario, formData: FormData) {
// ...
}
// En el componente
const guardarEste = guardarDepartamento.bind(null, departamento.deptId)
// guardarEste(anterior, formData) llamará a guardarDepartamento(deptId, anterior, formData)
El primer argumento de bind es el valor de this, que en una acción no se usa (null). Los siguientes se colocan delante de los que pase React. React sabe serializar una función ligada a una Server Action: viaja la referencia y los argumentos fijados.
Con un campo oculto: <input type="hidden" name="deptId" value={deptId} />, y en la acción formData.get('deptId').
bind | Campo oculto | |
|---|---|---|
| Tipo del valor | Se conserva (number, objetos…) | Siempre string |
| Funciona sin JavaScript | Sí | Sí |
| Visible en el HTML | Sí (como $ACTION_1:1, lo verás en el apartado 11) | Sí |
| Firma de la acción | Argumento propio, tipado | Hay que sacarlo del FormData |
En los dos casos el valor lo puede cambiar el usuario antes de enviar. bind no es una forma segura de “esconder” un dato: es una forma cómoda de pasarlo. La acción siempre tiene que comprobar que ese deptId existe y que el usuario puede modificarlo.
5. Refrescar los datos después de mutar
Volvamos a la pregunta del apartado 3: la lista está en caché durante horas. Si crearRapido solo hiciera el POST a la API, el usuario no vería su departamento. Next.js ofrece cuatro funciones, todas en next/cache, para decirle “esto ha cambiado”.
5.1. updateTag: lee lo que acabas de escribir 🆕 16.0
🆕 Desde Next.js 16.0. No existe en Next.js 15. Si usas Next.js 15: llama a
revalidateTag('departamentos')con un solo argumento, que en esa versión hace caducar la etiqueta al momento y la acción devuelve la página actualizada, con el mismo efecto (comprobado con Next.js 15.5.28, ejercicio 8).
updateTag(etiqueta) marca como caducadas ya todas las entradas de caché con esa etiqueta. La siguiente lectura (que es el renderizado de la página que Next hace dentro de la misma respuesta de la acción) espera a tener datos frescos. Es lo que se llama read-your-own-writes: quien escribe ve su cambio inmediatamente.
export async function crearRapido(formData: FormData) {
// ...
await escribirEnApi('/departamentos', 'POST', aCuerpo(valores))
updateTag('departamentos')
}
Solo se puede llamar desde una Server Action (no desde un Route Handler ni desde un componente). Es la función que usarás en el 90 % de los formularios.
5.2. revalidateTag con perfil 🔄 16.0
🔄 Cambia en Next.js 16.0. En Next.js 15,
revalidateTag(etiqueta)tiene un solo argumento y hace caducar la etiqueta al instante. En 16 exige un segundo argumento, el perfil decacheLife('max','hours'…), y pasa a ser stale-while-revalidate. Si usas Next.js 15: llama arevalidateTag('departamentos')con un argumento; no hay forma de pedir el modo “sirve lo viejo mientras refrescas”.
En Next.js 16, revalidateTag('departamentos', 'max') marca la etiqueta para refrescarla en segundo plano: las siguientes lecturas reciben el dato antiguo mientras Next obtiene el nuevo. Por eso la respuesta de la acción no incluye la página actualizada. Es útil cuando el cambio no lo tiene que ver quien lo hace en ese instante (por ejemplo, una acción que recalcula estadísticas o un webhook), y es lo que debes usar en un Route Handler, donde updateTag no está permitido.
5.3. revalidatePath
revalidatePath('/departamentos') invalida todo lo que se guardó al renderizar esa ruta. Existe en las dos versiones y es útil cuando no has etiquetado los datos, o cuando quieres invalidar una ruta concreta:
revalidatePath('/departamentos') // esa página
revalidatePath('/departamentos/[id]', 'page') // todas las páginas de detalle
revalidatePath('/', 'layout') // todo el sitio
Es menos preciso que las etiquetas: si la lista de departamentos también se muestra en /empleados, revalidatePath('/departamentos') no la refresca allí, y updateTag('departamentos') sí, porque la etiqueta va con el dato y no con la página.
5.4. refresh 🆕 16.0
🆕 Desde Next.js 16.0. No existe en Next.js 15. Si usas Next.js 15: llama a
revalidatePathcon la ruta actual dentro de la acción, o arouter.refresh()desde el cliente después de que termine.
refresh() vuelve a renderizar la ruta actual sin invalidar nada de la caché. Sirve cuando la pantalla depende de algo que no está en caché (una cookie, una lectura dinámica después de connection()) y la acción lo ha cambiado. Si los datos están en caché, refresh() volverá a mostrar los mismos datos viejos: para eso están updateTag o revalidatePath.
5.5. Cuál elegir
Figura 3. Qué función llamar después de una mutación.
| Función | Next.js 15 | Re-renderiza en la misma respuesta | Dónde se puede llamar |
|---|---|---|---|
updateTag(tag) | No existe | Sí, con datos frescos | Solo Server Actions |
revalidateTag(tag, perfil) | revalidateTag(tag), inmediato | No (refresco en segundo plano) | Actions y Route Handlers |
revalidatePath(ruta) | Igual | Sí | Actions y Route Handlers |
refresh() | No existe | Sí, sin tocar la caché | Solo Server Actions |
cookies().set(...) | Igual | Sí | Actions y Route Handlers |
Ninguna de ellas lanza una excepción, así que la acción puede seguir y devolver un valor. redirect, en cambio, sí lanza, como verás ahora.
📘 Concepto: “Una sola ida y vuelta”. Cuando la acción llama a
updateTag,revalidatePathorefresh(o cambia una cookie), Next ejecuta la acción, vuelve a renderizar la página en el mismo servidor y en la misma petición, y devuelve las dos cosas juntas: el valor de retorno y el nuevo RSC Payload. Lo viste en la respuesta del apartado 1.1 (x-action-revalidated: 1). No hay una segunda petición para recargar la lista.
6. useActionState: devolver un resultado al formulario
crearRapido tiene un defecto grave: si algo va mal, el usuario no se entera. Para mostrar errores y mensajes, la acción tiene que devolver algo, y el formulario tiene que leerlo. Eso hace el hook useActionState de React.
const [estado, formAction, pendiente] = useActionState(accion, estadoInicial)
acciones una función(estadoAnterior, formData) => nuevoEstado. Fíjate: conuseActionState, la acción recibe primero el estado anterior y después elFormData.estadoempieza valiendoestadoInicialy, después de cada envío, vale lo que devolvió la acción.formActiones la acción “envuelta” que pones en<form action={formAction}>.pendienteestruemientras la acción se ejecuta.
useActionState es un hook, así que solo se usa en Client Components.
6.1. El estado del formulario
Definimos un tipo para el estado. Incluye los valores enviados, por una razón que verás enseguida:
// app/departamentos/acciones.ts
export type EstadoFormulario = {
ok: boolean
mensaje: string
errores: Record<string, string>
valores: { nombre: string; localidad: string; presupuesto: string }
}
6.2. La acción
// app/departamentos/acciones.ts
export async function crearDepartamento(
_anterior: EstadoFormulario,
formData: FormData,
): Promise<EstadoFormulario> {
const valores = leerCampos(formData)
const errores: Record<string, string> = {}
if (!valores.nombre) errores.nombre = 'Escribe un nombre'
if (valores.presupuesto && Number.isNaN(Number(valores.presupuesto))) {
errores.presupuesto = 'El presupuesto debe ser un número'
}
if (Object.keys(errores).length > 0) {
return { ok: false, mensaje: 'Revisa los campos marcados', errores, valores }
}
const respuesta = await escribirEnApi<Departamento>('/departamentos', 'POST', aCuerpo(valores))
if (!respuesta.ok) {
return {
ok: false,
mensaje: respuesta.mensaje,
errores: respuesta.detalles ?? {},
valores,
}
}
updateTag('departamentos')
return {
ok: true,
mensaje: `Departamento ${respuesta.datos.deptId} creado: ${respuesta.datos.nombre}`,
errores: {},
valores: { nombre: '', localidad: '', presupuesto: '' },
}
}
La acción valida dos veces: primero ella misma (campos vacíos, números) y después la API, que puede rechazar con un 422 y errores por campo (detalles). Los dos tipos de error acaban en el mismo sitio: errores, un objeto con un mensaje por campo. La validación con un esquema (Zod) llega en el módulo 11; aquí lo hacemos a mano para ver el mecanismo.
6.3. El formulario
// app/departamentos/formulario-departamento.tsx
'use client'
import { useActionState } from 'react'
import type { EstadoFormulario } from './acciones'
import { BotonEnviar } from './boton-enviar'
type Accion = (anterior: EstadoFormulario, formData: FormData) => Promise<EstadoFormulario>
export function FormularioDepartamento({
accion,
inicial,
textoBoton,
}: {
accion: Accion
inicial: EstadoFormulario
textoBoton: string
}) {
const [estado, formAction] = useActionState(accion, inicial)
return (
<form action={formAction} className="bg-white p-6 shadow-sm space-y-4" noValidate>
{estado.mensaje && (
<p role="status" className={estado.ok ? 'rounded bg-green-100 p-3 text-green-900' : 'rounded bg-red-100 p-3 text-red-900'}>
{estado.mensaje}
</p>
)}
<Campo nombre="nombre" etiqueta="Nombre" valor={estado.valores.nombre} error={estado.errores.nombre} />
<Campo nombre="localidad" etiqueta="Localidad" valor={estado.valores.localidad} error={estado.errores.localidad} />
<Campo nombre="presupuesto" etiqueta="Presupuesto (euros)" valor={estado.valores.presupuesto} error={estado.errores.presupuesto} />
<BotonEnviar texto={textoBoton} textoPendiente="Guardando" />
</form>
)
}
function Campo({ nombre, etiqueta, valor, error }: { nombre: string; etiqueta: string; valor: string; error?: string }) {
return (
<label className="block">
<span className="block font-medium">{etiqueta}</span>
{/* key: al cambiar el valor devuelto por la acción, React vuelve a crear el input con su defaultValue */}
<input key={valor} name={nombre} defaultValue={valor} aria-invalid={!!error} className={`border p-2 w-full ${error ? 'border-red-600' : ''}`} />
{error && <small className="text-red-700">{error}</small>}
</label>
)
}
Y la página, que es un Server Component y solo elige la acción y el estado inicial:
// app/departamentos/nuevo/page.tsx
import { crearDepartamento, type EstadoFormulario } from '../acciones'
import { FormularioDepartamento } from '../formulario-departamento'
const inicial: EstadoFormulario = {
ok: false,
mensaje: '',
errores: {},
valores: { nombre: '', localidad: '', presupuesto: '' },
}
export default function NuevoDepartamento() {
return (
<>
<h1 className="text-2xl font-bold mb-4">Nuevo departamento</h1>
<FormularioDepartamento accion={crearDepartamento} inicial={inicial} textoBoton="Crear departamento" />
</>
)
}
Al enviar el formulario sin nombre y con “mucho” como presupuesto, la acción devuelve dos errores de campo:

Figura 4. Errores por campo devueltos por la acción. La localidad y el presupuesto conservan lo que escribió el usuario.
Con un nombre que ya existe, el error lo pone la API (422) y llega por el mismo camino:
Hay datos no válidos
Nombre
Ya existe un departamento con ese nombre
Y con datos correctos, la acción devuelve el mensaje de éxito y los valores vacíos:

Figura 5. Departamento creado. La API ha asignado el identificador 71 y el formulario queda listo para otro.
6.4. Por qué devolvemos los valores: React vacía el formulario
Puede sorprenderte que el estado incluya valores. ¿No se quedan los campos como estaban? No. Desde React 19, cuando una acción de <form action> termina, React restablece los campos no controlados del formulario (como hace el navegador con un formulario HTML tradicional, que recarga la página). Si la acción devuelve un error y no le devuelves los valores, el usuario pierde todo lo que escribió.
Por eso el patrón es:
- La acción devuelve en
valoreslo que recibió (si hubo error) o cadenas vacías (si salió bien). - Cada
<input>usadefaultValue={valor}. - El
key={valor}obliga a React a volver a crear el<input>cuando cambia el valor devuelto, para que tome el nuevodefaultValue. Sin lakey,defaultValuesolo se lee la primera vez.
La alternativa es usar campos controlados (value y onChange con useState), que no se restablecen. Funciona, pero pierdes la mejora progresiva (sin JavaScript, el estado no existe) y escribes más código. En el módulo 11 volveremos sobre esta decisión.
⚠️ Importante: El estado inicial de
useActionStateviaja en el HTML. Para que el formulario funcione sin JavaScript, React escribe la referencia de la acción y el estado actual en campos ocultos. En la página de edición del laboratorio aparece así:<input type="hidden" name="$ACTION_1:1" value="[30,{"ok":false,"mensaje":"",...,"valores":{"nombre":"INFORMÁTICA",...}}]">El
30es eldeptIdfijado conbind, y lo demás es el estado inicial. No pongas en el estado nada que el usuario no deba ver.
7. useFormStatus: el botón que sabe que se está enviando
useActionState ya da un pendiente, pero a veces quieres que un componente dentro del formulario (el botón de enviar, un indicador) sepa que el formulario se está enviando, sin pasarle props. Para eso está useFormStatus, de react-dom:
// app/departamentos/boton-enviar.tsx
'use client'
import { useFormStatus } from 'react-dom'
export function BotonEnviar({ texto, textoPendiente }: { texto: string; textoPendiente: string }) {
const { pending, data } = useFormStatus()
const nombre = data?.get('nombre')
return (
<button type="submit" disabled={pending} className="bg-indigo-700 text-white px-4 py-2 rounded disabled:opacity-60">
{pending ? `${textoPendiente} ${nombre ? `«${nombre}»` : ''}...` : texto}
</button>
)
}
useFormStatus() devuelve el estado del <form> que contiene al componente:
| Propiedad | Valor |
|---|---|
pending | true mientras se envía |
data | El FormData que se está enviando (o null) |
method | 'get' o 'post' |
action | La función o URL de la acción |
Para capturar el estado de envío, en el laboratorio retrasamos la petición 1,5 segundos con Playwright:

Figura 6. useFormStatus en acción. El botón se desactiva y muestra el nombre que se está enviando, sacado de data. Los errores del intento anterior siguen ahí hasta que llega la respuesta.
⚠️ Importante:
useFormStatussolo funciona en un componente hijo del<form>. Si lo llamas en el mismo componente que renderiza el<form>, siempre devuelvepending: false, porque busca un formulario por encima de él. Es el error más frecuente con este hook. Por esoBotonEnviares un componente aparte.
Desactivar el botón mientras se envía no es un adorno: evita los dobles envíos, que en un alta significan dos departamentos iguales (o un 422 de la API en el segundo).
8. Redirigir después de mutar
En la página de edición, al guardar queremos volver a la lista y mostrar un aviso. La acción termina con redirect:
// app/departamentos/acciones.ts
export async function guardarDepartamento(
deptId: number,
_anterior: EstadoFormulario,
formData: FormData,
): Promise<EstadoFormulario> {
if (!Number.isInteger(deptId)) {
return { ok: false, mensaje: 'Identificador no válido', errores: {}, valores: leerCampos(formData) }
}
const valores = leerCampos(formData)
const respuesta = await escribirEnApi<Departamento>(`/departamentos/${deptId}`, 'PUT', aCuerpo(valores))
if (!respuesta.ok) {
return { ok: false, mensaje: respuesta.mensaje, errores: respuesta.detalles ?? {}, valores }
}
updateTag('departamentos')
redirect(`/departamentos?guardado=${deptId}`)
}
// app/departamentos/[id]/editar/page.tsx
import { Suspense } from 'react'
import { notFound } from 'next/navigation'
import { obtenerDepartamento } from '@/lib/datos'
import { guardarDepartamento } from '../../acciones'
import { FormularioDepartamento } from '../../formulario-departamento'
async function Editor({ params }: { params: PageProps<'/departamentos/[id]/editar'>['params'] }) {
const { id } = await params
const departamento = await obtenerDepartamento(Number(id))
if (!departamento) notFound()
// bind fija el primer argumento: la acción recibirá (deptId, estadoAnterior, formData)
const guardarEste = guardarDepartamento.bind(null, departamento.deptId)
return (
<FormularioDepartamento
accion={guardarEste}
textoBoton="Guardar cambios"
inicial={{
ok: false,
mensaje: '',
errores: {},
valores: {
nombre: departamento.nombre,
localidad: departamento.localidad ?? '',
presupuesto: departamento.presupuesto?.toString() ?? '',
},
}}
/>
)
}
export default function EditarDepartamento(props: PageProps<'/departamentos/[id]/editar'>) {
return (
<>
<h1 className="text-2xl font-bold mb-4">Editar departamento</h1>
<Suspense fallback={<p>Cargando...</p>}>
<Editor params={props.params} />
</Suspense>
</>
)
}
El mismo FormularioDepartamento sirve para crear y para editar: solo cambian la acción y el estado inicial. Y la lista muestra el aviso leyendo searchParams dentro de su propio <Suspense> (con cacheComponents, leer searchParams es un dato de la petición y no puede estar en la parte estática, como viste en el módulo 04):
// app/departamentos/page.tsx (fragmento)
async function AvisoGuardado({ searchParams }: { searchParams: PageProps<'/departamentos'>['searchParams'] }) {
const { guardado } = await searchParams
if (!guardado) return null
return <p className="mb-4 rounded bg-green-100 p-3 text-green-900">Cambios guardados en el departamento {guardado}.</p>
}
// en el JSX de la página:
<Suspense fallback={null}>
<AvisoGuardado searchParams={props.searchParams} />
</Suspense>
Al cambiar la localidad de VENTAS a Barcelona y guardar:
EDITAR url http://127.0.0.1:5510/departamentos?guardado=20 | fila 20 VENTAS Barcelona

Figura 7. Después de redirect, la lista ya muestra el cambio: updateTag se ejecutó antes, y la página de destino se renderizó con datos frescos.
Tres reglas de redirect en una acción:
- Revalida antes de redirigir.
redirectlanza una excepción especial; el código que va detrás no se ejecuta nunca. Si ponesupdateTagdespués, la lista no se actualiza. - El tipo de retorno no importa.
redirectdevuelveneverpara TypeScript, así que la acción cumple su tipoPromise<EstadoFormulario>aunque el camino feliz no devuelva nada. - No lo metas en un
try. Lo explicamos ahora.
8.1. La trampa de redirect dentro de try
redirect funciona lanzando un error especial (NEXT_REDIRECT) que Next captura más arriba para hacer la redirección. Si lo envuelves en un try/catch que captura todo, te lo comes tú:
// app/demo/try/acciones.ts
'use server'
import { redirect, unstable_rethrow } from 'next/navigation'
export type Resultado = { mensaje: string }
export async function redirigirMal(_: Resultado, _formData: FormData): Promise<Resultado> {
try {
// ... llamada a la API que puede fallar ...
redirect('/departamentos')
} catch (error) {
console.log('[demo-try] capturado:', (error as Error).message)
return { mensaje: 'Algo ha fallado' }
}
}
export async function redirigirBien(_: Resultado, _formData: FormData): Promise<Resultado> {
try {
// ... llamada a la API que puede fallar ...
redirect('/departamentos')
} catch (error) {
unstable_rethrow(error) // deja pasar redirect, notFound y compañía
return { mensaje: 'Algo ha fallado' }
}
}
Resultado real:
# Terminal del servidor
[demo-try] capturado: NEXT_REDIRECT
# Navegador
TRY mal http://127.0.0.1:5510/demo/try Algo ha fallado
TRY bien http://127.0.0.1:5510/departamentos
redirigirMal no redirige y además muestra un error falso al usuario, aunque no ha fallado nada. Hay dos soluciones:
- La más clara: saca
redirectfuera deltry. Dentro deltrysolo va lo que puede fallar de verdad (la llamada a la API). - Si no puedes: llama a
unstable_rethrow(error)al principio delcatch. Vuelve a lanzar los errores internos de Next (redirect,notFound,forbidden…) y deja pasar los demás. A pesar del prefijounstable_, existe tanto en Next.js 16 como en 15.
💡 Consejo: Nuestro
escribirEnApidevuelve los errores en lugar de lanzarlos precisamente para no necesitartry/catchen las acciones. Menostry, menos ocasiones de tragarse unredirect.
9. Llamar a acciones fuera de un formulario
No todo es un formulario. Un botón “Borrar” en cada fila, un interruptor, un contador de visitas… Una Server Action es, al final, una función asíncrona: un Client Component puede importarla y llamarla en un manejador de eventos.
9.1. Con useTransition
// app/departamentos/boton-borrar.tsx
'use client'
import { useState, useTransition } from 'react'
import { borrarDepartamento } from './acciones'
export function BotonBorrar({ deptId }: { deptId: number }) {
const [pendiente, startTransition] = useTransition()
const [error, setError] = useState<string | null>(null)
function borrar() {
if (!confirm(`¿Borrar el departamento ${deptId}?`)) return
startTransition(async () => {
const resultado = await borrarDepartamento(deptId)
setError(resultado.ok ? null : resultado.mensaje)
})
}
return (
<span>
<button onClick={borrar} disabled={pendiente} className="text-red-700 underline disabled:opacity-50">
{pendiente ? 'Borrando...' : 'Borrar'}
</button>
{error && <small className="block text-red-700" role="alert">{error}</small>}
</span>
)
}
// app/departamentos/acciones.ts
export async function borrarDepartamento(deptId: number) {
const respuesta = await escribirEnApi(`/departamentos/${deptId}`, 'DELETE')
if (!respuesta.ok) {
return { ok: false as const, mensaje: respuesta.mensaje }
}
updateTag('departamentos')
return { ok: true as const, mensaje: `Departamento ${deptId} borrado` }
}
¿Por qué startTransition? Porque así React trata la llamada como una Action: pendiente vale true mientras dura, y cuando la acción devuelve la página actualizada (por el updateTag), React la aplica dentro de la misma transición, sin parpadeos. La acción se llama con un argumento normal, deptId, y viaja como [10] en el cuerpo de la petición.
La API no deja borrar un departamento con empleados (409). El error llega como valor de retorno y se muestra junto al botón:

Figura 8. Un error esperado (409 de la API) devuelto por la acción y mostrado en la fila. Al borrar I+D, que no tiene empleados, la fila desaparece sin recargar.
9.2. Las acciones se ejecutan de una en una
Un detalle de Next que sorprende: el cliente envía las acciones de una en una. Si lanzas tres a la vez, la segunda espera a que termine la primera. Lo comprobamos con una acción que tarda 500 ms y un Promise.all:
// app/demo/secuencial/acciones.ts
'use server'
export async function tarea(n: number) {
const inicio = Date.now()
await new Promise((r) => setTimeout(r, 500))
return { n, inicio, fin: Date.now() }
}
// app/demo/secuencial/secuencial.tsx
'use client'
import { useState } from 'react'
import { tarea } from './acciones'
export function Secuencial() {
const [lineas, setLineas] = useState<string[]>([])
async function lanzar() {
const t0 = Date.now()
const resultados = await Promise.all([tarea(1), tarea(2), tarea(3)])
setLineas([
...resultados.map((r) => `tarea ${r.n}: empieza en +${r.inicio - t0} ms, acaba en +${r.fin - t0} ms`),
`total: ${Date.now() - t0} ms`,
])
}
return (
<>
<button onClick={lanzar}>Lanzar</button>
<pre>{lineas.join('\n')}</pre>
</>
)
}
Salida real:
tarea 1: empieza en +66 ms, acaba en +580 ms
tarea 2: empieza en +657 ms, acaba en +1168 ms
tarea 3: empieza en +1195 ms, acaba en +1726 ms
total: 1751 ms
Tres veces 500 ms, no 500 ms. Next lo hace a propósito: cada acción puede devolver una versión nueva de la página, y si se ejecutaran a la vez, las respuestas podrían llegar desordenadas y dejar en pantalla un estado que no corresponde a la última acción. Es una propiedad del despachador del cliente: el servidor no pone en cola nada, y otra pestaña u otro usuario se atienden en paralelo.
Consecuencias prácticas:
- No uses Server Actions para leer datos desde el cliente. Para leer está el Server Component (módulo 08), o un Route Handler si de verdad lo necesitas desde el cliente.
- Si necesitas trabajo en paralelo, hazlo dentro de una sola acción (
Promise.allen el servidor). Es el ejercicio 9.
10. useOptimistic: mostrar el resultado antes de tenerlo
Cambiar el estado de un pedido tarda lo que tarde la API. Si el usuario pulsa “Marcar enviado” y no ve nada durante un segundo, pensará que no ha funcionado y volverá a pulsar. La interfaz optimista muestra el resultado esperado en cuanto el usuario actúa, y lo corrige si el servidor dice otra cosa.
const [optimista, aplicar] = useOptimistic(valorReal, (actual, cambio) => nuevoValor)
valorReales el dato que viene del servidor (la prop).aplicar(cambio)produce un valor provisional usando la función reductora.- Mientras la transición está en curso,
optimistavale el provisional. Cuando la transición termina, React lo descarta y vuelve avalorReal, que para entonces ya es el dato nuevo (porque la acción hizoupdateTagy la página llegó actualizada) o el viejo (si la acción falló).
La página de pedidos lee los seis últimos y pasa la lista a un Client Component:
// app/pedidos/page.tsx
import { obtenerPedidosPendientes } from '@/lib/datos'
import { ListaPedidos } from './lista-pedidos'
export default async function PaginaPedidos() {
const pedidos = await obtenerPedidosPendientes()
return (
<>
<h1 className="text-2xl font-bold mb-4">Últimos pedidos</h1>
<ListaPedidos pedidos={pedidos} />
</>
)
}
// app/pedidos/acciones.ts
'use server'
import { updateTag } from 'next/cache'
import { escribirEnApi } from '@/lib/api'
import type { Pedido } from '@/lib/datos'
const ESTADOS: Pedido['estado'][] = ['PENDIENTE', 'ENVIADO', 'ENTREGADO', 'CANCELADO']
export async function cambiarEstado(pedidoId: number, estado: Pedido['estado']) {
// Los argumentos llegan del navegador: no te fíes de los tipos de TypeScript
if (!Number.isInteger(pedidoId) || !ESTADOS.includes(estado)) {
return { ok: false, mensaje: 'Datos no válidos' }
}
await new Promise((r) => setTimeout(r, Number(process.env.RETARDO_ACCION ?? 0))) // para ver la UI optimista
const respuesta = await escribirEnApi(`/pedidos/${pedidoId}`, 'PATCH', { estado })
if (!respuesta.ok) return { ok: false, mensaje: respuesta.mensaje }
updateTag('pedidos')
return { ok: true, mensaje: `Pedido ${pedidoId}: ${estado}` }
}
// app/pedidos/lista-pedidos.tsx
'use client'
import { useOptimistic, useState, startTransition } from 'react'
import type { Pedido } from '@/lib/datos'
import { cambiarEstado } from './acciones'
type Cambio = { pedidoId: number; estado: Pedido['estado'] }
export function ListaPedidos({ pedidos }: { pedidos: Pedido[] }) {
const [error, setError] = useState<string | null>(null)
const [optimistas, aplicarCambio] = useOptimistic(
pedidos,
(actuales: Pedido[], cambio: Cambio) =>
actuales.map((p) => (p.pedidoId === cambio.pedidoId ? { ...p, estado: cambio.estado, pendiente: true } : p)),
)
function marcar(pedidoId: number, estado: Pedido['estado']) {
startTransition(async () => {
aplicarCambio({ pedidoId, estado })
const resultado = await cambiarEstado(pedidoId, estado)
setError(resultado.ok ? null : resultado.mensaje)
})
}
return (
<>
{error && <p role="alert" className="mb-3 rounded bg-red-100 p-3 text-red-900">{error}</p>}
<ul className="space-y-2">
{optimistas.map((p) => {
const enCurso = 'pendiente' in p
return (
<li key={p.pedidoId} className={`bg-white p-3 shadow-sm flex justify-between items-center ${enCurso ? 'opacity-60' : ''}`}>
<span>
<strong>{p.pedidoId}</strong> · {p.cliente} · {p.total.toFixed(2)} €
</span>
<span className="flex gap-2 items-center">
<span className="font-mono text-sm">{p.estado}{enCurso ? ' (guardando)' : ''}</span>
{p.estado === 'PENDIENTE' && (
<button className="bg-indigo-700 text-white px-3 py-1 rounded" onClick={() => marcar(p.pedidoId, 'ENVIADO')}>
Marcar enviado
</button>
)}
{p.estado === 'PENDIENTE' && (
<button className="border border-red-700 text-red-700 px-3 py-1 rounded" onClick={() => marcar(p.pedidoId, 'CANCELADO')}>
Cancelar
</button>
)}
</span>
</li>
)
})}
</ul>
</>
)
}
Con RETARDO_ACCION=1500 en .env.local, 300 ms después de pulsar “Marcar enviado” en el pedido 5024:

Figura 9. Estado optimista: la fila ya dice ENVIADO, atenuada y con “(guardando)”, aunque la API todavía no ha respondido.
Y 2,5 segundos después, con la respuesta ya aplicada:
CONFIRMADO 5024 · Gestoría Delta · 1497.00 €
ENVIADO
5023 · Laura Benítez Sanz · 418.80 €
PENDIENTE
...
El “(guardando)” ha desaparecido porque optimistas ha vuelto a ser pedidos, que ahora viene del servidor con el estado real. Tres detalles importantes:
aplicarCambiotiene que llamarse dentro de una transición (startTransitiono una acción de formulario). El valor provisional vive exactamente lo que dura esa transición; fuera de ella no hay nada que lo mantenga, y React lo señala como un error de uso.- No hace falta “deshacer” a mano si la acción falla: al terminar la transición, React vuelve al valor real, que sigue siendo el antiguo. Solo tienes que mostrar el error.
- El campo
pendiente: trueque añade la reductora es un truco para pintar distinto lo provisional. Como no existe en los datos reales, desaparece solo.
💡 Consejo: Usa la interfaz optimista cuando el éxito es lo normal y el error es raro (marcar, cambiar un estado, “me gusta”). Para operaciones que fallan a menudo o tienen consecuencias serias (un pago, un borrado), es mejor un estado de “guardando” honesto: decirle al usuario que algo ha salido bien cuando no es así y corregirlo después confunde.
11. Errores: esperados e inesperados
En una acción hay dos clases de errores, y se tratan de forma distinta:
| Esperados | Inesperados | |
|---|---|---|
| Ejemplos | Campo vacío, nombre repetido (422), departamento con empleados (409), sin permiso | La API no responde, un fallo de programación, la base de datos caída |
| Cómo se tratan | Se devuelven como valor: { ok: false, mensaje } | Se lanzan (throw) |
| Quién los muestra | El propio formulario, con useActionState o el resultado de la llamada | El error.tsx más cercano |
| Mensaje que ve el usuario | El tuyo, completo | En producción, uno genérico |
11.1. Qué ve el usuario cuando una acción lanza
// app/demo/error/page.tsx
export default function DemoError() {
async function fallar() {
'use server'
throw new Error('Fallo interno: la tabla PEDIDOS está bloqueada')
}
return (
<form action={fallar}>
<button>Ejecutar</button>
</form>
)
}
// app/demo/error/error.tsx
'use client'
export default function ErrorDemo({ error, retry }: { error: Error & { digest?: string }; retry: () => void }) {
return (
<div role="alert" className="rounded bg-red-100 p-4 text-red-900 space-y-2">
<h2 className="font-bold">No se ha podido completar la operación</h2>
<p>{error.message}</p>
<p className="text-sm">Código para soporte: <code>{error.digest}</code></p>
<button onClick={retry} className="underline">Reintentar</button>
</div>
)
}
Con next build y next start:

Figura 10. En producción, el mensaje original no llega al navegador: solo un error genérico y un digest.
Y en la terminal del servidor, el error real con el mismo digest:
⨯ Error: Fallo interno: la tabla PEDIDOS está bloqueada
at d (...\.next\server\chunks\ssr\_0ihknd2r4ajlj._.js:1:413)
...
digest: '2782986613'
}
Es una medida de seguridad: el mensaje de una excepción puede contener detalles internos (nombres de tablas, rutas, consultas) que no deben salir del servidor. El digest es el puente: el usuario te lo da y tú lo buscas en los registros. En desarrollo (next dev), en cambio, verás el mensaje completo para poder depurar.
Conclusión práctica: nunca uses throw para comunicar al usuario algo que debe leer. Si quieres que vea “Ya existe un departamento con ese nombre”, devuélvelo. El error.tsx y retry se estudian a fondo en el módulo 14.
12. Cierres y cifrado
Una acción declarada dentro de un Server Component puede usar variables del componente. ¿Cómo llega ese valor al servidor cuando el usuario pulsa el botón, minutos después? Viajando al navegador y volviendo. Lo comprobamos con dos acciones, una que captura una variable y otra que la recibe con bind:
// app/demo/cierre/page.tsx
import { revalidatePath } from 'next/cache'
async function registrar(etiqueta: string, valor: string) {
console.log(`[demo-cierre] ${etiqueta}: ${valor}`)
}
export default function DemoCierre() {
const codigoInterno = 'CLAVE-INTERNA-7731' // capturada por el cierre
const valorLigado = 'VALOR-LIGADO-4242' // pasado con bind
async function conCierre() {
'use server'
await registrar('cierre', codigoInterno)
revalidatePath('/demo/cierre')
}
async function conArgumento(valor: string) {
'use server'
await registrar('bind', valor)
}
return (
<>
<form action={conCierre}><button>Acción con cierre</button></form>
<form action={conArgumento.bind(null, valorLigado)}><button>Acción con bind</button></form>
</>
)
}
Estos son los campos ocultos que Next escribe en el HTML de esa página:
<!-- Formulario 1: acción con cierre -->
<input type="hidden" name="$ACTION_REF_0"/>
<input type="hidden" name="$ACTION_0:2" value=""offd9QdvAQnKIY6e8EnX0O2m4aBkRU9MAvdEL4EtEfNa5/d8/EM5VhxW5sh/VnPB0WkorrT8R/oFSrUDFIkul+vTpugVBIxoDm2Qr16VD5g9b9Gl9h6HSiCr5zpiPsrujLQI""/>
<input type="hidden" name="$ACTION_0:1" value="["$@2"]"/>
<input type="hidden" name="$ACTION_0:0" value="{"id":"4022e5ed7a8ecfb22423cb5dfb97515df4301df26c","bound":"$@1"}"/>
<!-- Formulario 2: acción con bind -->
<input type="hidden" name="$ACTION_REF_1"/>
<input type="hidden" name="$ACTION_1:1" value="["VALOR-LIGADO-4242"]"/>
<input type="hidden" name="$ACTION_1:0" value="{"id":"40ebdb90bdbb93147cb1046d2bdc87074f737f7e9c","bound":"$@1"}"/>
Buscando las dos cadenas en el HTML completo de la página:
VALOR-LIGADO-4242 aparece (en el campo $ACTION_1:1 y en el RSC Payload)
CLAVE-INTERNA-7731 no aparece
La diferencia es importante:
- Las variables capturadas por un cierre se cifran. Next genera una clave en cada build y cifra los valores capturados antes de enviarlos (el bloque
offd9Qdv...). Al volver, los descifra en el servidor. - Los argumentos de
bindno se cifran. Viajan en claro, y el usuario puede leerlos y cambiarlos.
⚠️ Importante: Ni siquiera el cifrado es una razón para meter secretos en un cierre. La documentación de Next lo dice expresamente: no confíes solo en el cifrado para proteger datos sensibles. Lo correcto es que la acción vuelva a obtener lo que necesite en el servidor (de la base de datos, de la sesión), y que el cliente solo envíe identificadores. Además, si despliegas en varios servidores, todos deben compartir la clave con la variable de entorno
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY, o un servidor no podrá descifrar lo que cifró otro.
13. Seguridad básica de las Server Actions
Una acción es un endpoint público. Vamos a demostrarlo y después a ver qué te protege Next y qué te toca a ti. La seguridad completa (sesiones, CSRF a fondo, taint, cabeceras) está en los módulos 17 y 22.
13.1. Cualquiera puede llamar a tu acción
El identificador de cambiarEstado está en el JavaScript que descarga cualquier visitante (y lo vimos en la cabecera next-action). Con él, un curl basta para cancelar un pedido sin pasar por la interfaz:
curl -s -i -X POST http://127.0.0.1:5510/pedidos \
-H "Next-Action: 60d2693886b967c783dcd8f3073469192a7281f85e" \
-H "Accept: text/x-component" \
-H "Content-Type: text/plain;charset=UTF-8" \
-H "Origin: http://127.0.0.1:5510" \
--data '[5023,"CANCELADO"]'
HTTP/1.1 200 OK
x-action-revalidated: 1
Content-Type: text/x-component
0:{"a":"$@1","q":"","i":false,"t":{...}}
1:{"ok":true,"mensaje":"Pedido 5023: CANCELADO"}
Y la API lo confirma: el pedido 5023 tiene ahora "estado":"CANCELADO". Que el botón “Cancelar” solo se pinte para ciertos usuarios no protege nada: la acción hay que protegerla dentro.
¿Y si el atacante envía basura? cambiarEstado valida los argumentos, porque TypeScript no existe en tiempo de ejecución:
curl ... --data '["5022; DROP",{"x":1}]'
1:{"ok":false,"mensaje":"Datos no válidos"}
Sin esa comprobación, pedidoId habría sido la cadena "5022; DROP" y estado un objeto, por mucho que la firma diga number y Pedido['estado'].
13.2. Lo que Next hace por ti
| Protección | Qué hace | Comprobado |
|---|---|---|
Solo POST | Un GET nunca ejecuta una acción. Un enlace o una imagen no pueden dispararla. | |
| Comprobación de origen (CSRF) | Compara la cabecera Origin con Host (o X-Forwarded-Host). Si no coinciden, aborta. | Sí: abajo |
| Límite de tamaño | El cuerpo de una acción no puede pasar de 1 MB por defecto (serverActions.bodySizeLimit). | |
| Identificadores no adivinables | 42 caracteres hexadecimales, regenerados en los despliegues. Un ID inventado da 409 x-nextjs-action-not-found. | Sí: abajo |
| Eliminación de acciones sin usar | Una acción exportada que ningún componente importa no recibe ID ni es invocable. | Sí: abajo |
| Cifrado de cierres | Apartado 12. | Sí |
Una petición desde otro origen:
curl ... -H "Origin: http://atacante.example" --data '[5022,"CANCELADO"]'
HTTP/1.1 500 Internal Server Error
0:{"a":"$@1","q":"","i":false,"b":"8SoEfvX3tXmnU8kkgPzpS"}
1:E{"digest":"2099598585"}
Y en el registro del servidor:
`x-forwarded-host` header with value `127.0.0.1:5510` does not match `origin` header with value
`atacante.example` from a forwarded Server Actions request. Aborting the action.
El pedido 5022 siguió PENDIENTE. Una página maliciosa en otro dominio no puede usar tu acción aprovechando las cookies del usuario. Si tu aplicación está detrás de un proxy o una CDN con otro dominio, añádelo a serverActions.allowedOrigins en next.config.ts:
// next.config.ts
const nextConfig: NextConfig = {
experimental: {
serverActions: {
allowedOrigins: ['empresa.example', '*.empresa.example'],
bodySizeLimit: '2mb',
},
},
}
Una petición sin cabecera Origin se deja pasar con un aviso, no se rechaza: los navegadores la envían siempre en un POST, pero curl no. La comprobación protege a los usuarios de tu web, no a tu acción de un programa que la llame directamente. Por eso sigue siendo imprescindible lo de abajo.
Un identificador inventado:
HTTP/1.1 409 Conflict
x-nextjs-action-not-found: 1
Error: Failed to find Server Action "60ffffffffffffffffffffffffffffffffffffffff".
This request might be from an older or newer deployment.
Este mismo error lo verán usuarios reales que tengan abierta una pestaña con la versión anterior de tu aplicación cuando despliegues una nueva: sus identificadores ya no existen. Por eso conviene que los errores de las acciones ofrezcan “Reintentar” o “Recargar”.
Y la acción huérfana: en el laboratorio, app/demo/secuencial/acciones.ts exporta tarea (usada) y accionHuerfana (que no importa nadie). En el manifiesto de acciones del build (.next/server/server-reference-manifest.json) solo aparece tarea: accionHuerfana no tiene identificador y no se puede invocar.
13.3. Lo que tienes que hacer tú
En cada acción, sin excepciones:
- Autentica y autoriza dentro de la acción. ¿Hay sesión? ¿Puede este usuario hacer esto con este recurso? (Módulo 17; de momento, nuestra API lo hace por nosotros con el token de servicio, lo cual es justo lo que no debe quedarse así en producción.)
- Valida los argumentos y el
FormData. Tipos, rangos, formatos. Zod en el módulo 11. - Recibe identificadores, no objetos completos. Si la acción recibe un
Pedidoentero del cliente, el atacante decide su contenido. RecibepedidoIdy vuelve a leer el pedido en el servidor. - Devuelve solo lo que la interfaz necesita. El valor de retorno se serializa y llega al navegador. No devuelvas el registro completo de la base de datos si solo vas a mostrar un mensaje.
- No confíes en lo oculto. Campos
hidden, argumentos debind, botones que no se pintan: el usuario puede cambiarlos o saltárselos.
⚠️ Importante: En diciembre de 2025 se publicaron vulnerabilidades graves en la implementación de React Server Components y Server Functions (CVE-2025-66478 y siguientes). Se corrigieron con parches de Next.js y React. La primera regla de seguridad de las Server Actions es mantener Next.js en el último parche de tu versión (16.4.x). Lo estudiaremos en el módulo 22.
14. Caso práctico: la gestión de departamentos completa
Juntando todo lo anterior, la zona de gestión de departamentos de EMPRESA queda así:
app/
├── departamentos/
│ ├── acciones.ts 'use server': crearRapido, crearRapidoPRG,
│ │ crearDepartamento, guardarDepartamento, borrarDepartamento
│ ├── page.tsx Lista (caché) + alta rápida + aviso de guardado
│ ├── boton-borrar.tsx 'use client': useTransition + acción con argumento
│ ├── boton-enviar.tsx 'use client': useFormStatus
│ ├── formulario-departamento.tsx 'use client': useActionState, crear y editar
│ ├── nuevo/page.tsx Alta completa
│ └── [id]/editar/page.tsx Edición con bind y redirect
├── pedidos/
│ ├── acciones.ts 'use server': cambiarEstado
│ ├── page.tsx Lista (caché)
│ └── lista-pedidos.tsx 'use client': useOptimistic
lib/
├── api.ts server-only: token de servicio, escribirEnApi, leerDeApi
└── datos.ts server-only: lecturas con 'use cache' y cacheTag
La página de la lista, completa:
// app/departamentos/page.tsx
import { Suspense } from 'react'
import Link from 'next/link'
import { obtenerDepartamentos } from '@/lib/datos'
import { crearRapido, crearRapidoPRG } from './acciones'
import { BotonBorrar } from './boton-borrar'
async function AvisoGuardado({ searchParams }: { searchParams: PageProps<'/departamentos'>['searchParams'] }) {
const { guardado } = await searchParams
if (!guardado) return null
return <p className="mb-4 rounded bg-green-100 p-3 text-green-900">Cambios guardados en el departamento {guardado}.</p>
}
async function TablaDepartamentos() {
const departamentos = await obtenerDepartamentos()
return (
<table className="w-full bg-white shadow-sm">
<thead className="bg-indigo-100 text-left">
<tr><th className="p-2">Id</th><th className="p-2">Nombre</th><th className="p-2">Localidad</th><th className="p-2"></th></tr>
</thead>
<tbody>
{departamentos.map((d) => (
<tr key={d.deptId} className="border-t">
<td className="p-2">{d.deptId}</td>
<td className="p-2">{d.nombre}</td>
<td className="p-2">{d.localidad ?? '-'}</td>
<td className="p-2 flex gap-3">
<Link className="text-indigo-700 underline" href={`/departamentos/${d.deptId}/editar`}>Editar</Link>
<BotonBorrar deptId={d.deptId} />
</td>
</tr>
))}
</tbody>
</table>
)
}
export default function PaginaDepartamentos(props: PageProps<'/departamentos'>) {
return (
<>
<h1 className="text-2xl font-bold mb-4">Departamentos</h1>
<Suspense fallback={null}>
<AvisoGuardado searchParams={props.searchParams} />
</Suspense>
<TablaDepartamentos />
<form action={crearRapido} className="mt-6 flex gap-2" id="alta-rapida">
<input name="nombre" placeholder="Nombre del departamento" className="border p-2 flex-1" />
<input name="localidad" placeholder="Localidad" className="border p-2" />
<button className="bg-indigo-700 text-white px-4 rounded">Crear</button>
<button formAction={crearRapidoPRG} id="crear-prg" className="border border-indigo-700 px-4 rounded">Crear (PRG)</button>
</form>
</>
)
}
Fíjate en el reparto, que sigue el método del módulo 05:
- La página, la tabla y el formulario de alta rápida son Server Components. No envían JavaScript al navegador, y el alta funciona sin él.
- Solo son Client Components las piezas que necesitan estado o eventos: el botón de borrar (confirmación y error por fila), el botón de enviar (estado del formulario), el formulario completo (estado devuelto) y la lista de pedidos (optimista).
- Toda la escritura está en dos ficheros
acciones.ts, y todo acceso a la API enlib/. Cuando en el módulo 16 cambiemos la API por una base de datos, las acciones apenas cambiarán: soloescribirEnApipor las consultas de Drizzle.
El build de este proyecto en Next.js 16.4.3:
Route (app) Revalidate Expire
┌ ○ /
├ ○ /_not-found
├ ○ /demo/cierre
├ ○ /demo/error
├ ○ /demo/secuencial
├ ○ /demo/try
├ ◐ /departamentos 1h 1d
├ /departamentos/[id]/editar
│ └ ◐ /departamentos/[id]/editar
├ ○ /departamentos/nuevo
└ ○ /pedidos 1h 1d
Las acciones no cambian el tipo de las rutas: /departamentos/nuevo sigue siendo estática (○) aunque su formulario escriba en la API. Una acción no es parte del renderizado: es una petición aparte.
15. Errores frecuentes
| Síntoma | Causa | Solución |
|---|---|---|
Only async functions are allowed to be exported in a "use server" file | Exportas una constante, un objeto o una función síncrona desde un fichero 'use server' | Mueve las constantes a otro fichero; haz async las funciones |
It is not allowed to define inline "use server" annotated Server Actions in Client Components | 'use server' dentro de una función de un Client Component | Pon la acción en un fichero 'use server' e impórtala, o pásala como prop |
| La lista no muestra el elemento recién creado | Falta updateTag/revalidatePath, o la etiqueta no coincide con la de cacheTag | Revalida con la misma etiqueta que usa la lectura |
| La lista se actualiza “a la segunda” | Usas revalidateTag(tag, 'max'), que refresca en segundo plano | Usa updateTag en la acción |
redirect no redirige y aparece un error | redirect dentro de un try/catch | Sácalo del try o usa unstable_rethrow(error) en el catch |
La lista no se actualiza tras redirect | updateTag está después de redirect y no se ejecuta | Revalida antes de redirigir |
useFormStatus siempre da pending: false | Se llama en el componente que renderiza el <form> | Ponlo en un componente hijo del formulario |
| El formulario se vacía al devolver un error | React restablece el formulario al terminar la acción | Devuelve los valores en el estado y úsalos como defaultValue (con key) |
useActionState da error de tipos con la acción | La acción no tiene la firma (estadoAnterior, formData) | Añade el primer parámetro aunque no lo uses (_anterior) |
| El usuario ve “Minified React error” en lugar de tu mensaje | La acción lanza un error para comunicar algo esperado | Devuelve { ok: false, mensaje }; reserva throw para lo inesperado |
| Tres acciones lanzadas a la vez tardan el triple | El cliente envía las acciones de una en una | Haz el trabajo en paralelo dentro de una sola acción |
Failed to find Server Action en producción | Pestaña abierta con una versión anterior tras un despliegue | Ofrece recargar; usa despliegues graduales |
| La acción se aborta detrás de un proxy | El Origin no coincide con el Host | Configura serverActions.allowedOrigins o que el proxy envíe X-Forwarded-Host |
Página en blanco tras enviar sin JavaScript en una ruta ◐ | La respuesta al POST sin JS solo trae la parte dinámica | Termina la acción con redirect (Post/Redirect/Get) |
next build falla al prerenderizar | La API no está en marcha durante el build | Arranca la API antes de compilar |
16. Ejercicios
Para todos: API EMPRESA en marcha, .env.local con las credenciales de servicio y los ficheros lib/api.ts y lib/datos.ts del apartado 3.
Ejercicio 1: Alta de departamento en una sola página
Sin Client Components, escribe una página /departamentos/alta-simple con un formulario (nombre y localidad) cuya acción esté declarada dentro del propio componente. Al crear, la lista de /departamentos debe mostrar el nuevo departamento al visitarla, y el navegador debe volver a ella.
Ver solución
// app/departamentos/alta-simple/page.tsx
import { updateTag } from 'next/cache'
import { redirect } from 'next/navigation'
import { escribirEnApi } from '@/lib/api'
export default function AltaSimple() {
async function crear(formData: FormData) {
'use server'
const nombre = String(formData.get('nombre') ?? '').trim()
const localidad = String(formData.get('localidad') ?? '').trim()
if (!nombre) return
await escribirEnApi('/departamentos', 'POST', { nombre, localidad: localidad || null })
updateTag('departamentos')
redirect('/departamentos')
}
return (
<form action={crear} className="space-y-3">
<input name="nombre" placeholder="Nombre" required className="border p-2 block" />
<input name="localidad" placeholder="Localidad" className="border p-2 block" />
<button className="bg-indigo-700 text-white px-4 py-2 rounded">Crear</button>
</form>
)
}
Puntos clave:
- La acción en línea lleva
'use server'como primera línea del cuerpo, no del fichero. updateTagva antes deredirect, porqueredirectlanza y lo que hay detrás no se ejecuta.- Al terminar con
redirect, el formulario funciona igual sin JavaScript: el servidor responde 303 y el navegador carga la lista (apartado 4.3). - El
requireddel<input>es una ayuda en el navegador; la comprobación de verdad es elif (!nombre)del servidor, porque cualquiera puede saltarse el HTML.
Ejercicio 2: Botón de envío reutilizable
Crea un componente BotonEnviar que se desactive mientras su formulario se envía y muestre “Enviando…”. Úsalo en el formulario del ejercicio 1 sin convertir la página en Client Component. Explica por qué no funcionaría si llamaras a useFormStatus en la página.
Ver solución
// app/componentes/boton-enviar.tsx
'use client'
import { useFormStatus } from 'react-dom'
export function BotonEnviar({ children }: { children: React.ReactNode }) {
const { pending } = useFormStatus()
return (
<button type="submit" disabled={pending} className="bg-indigo-700 text-white px-4 py-2 rounded disabled:opacity-60">
{pending ? 'Enviando...' : children}
</button>
)
}
// app/departamentos/alta-simple/page.tsx (cambia solo el botón)
import { BotonEnviar } from '@/app/componentes/boton-enviar'
// ...
<BotonEnviar>Crear</BotonEnviar>
La página sigue siendo un Server Component: solo el botón es cliente, y es un punto de entrada al grafo del cliente (módulo 05).
No funcionaría en la página por dos motivos. Primero, useFormStatus es un hook y la página es un Server Component, así que no puede usar hooks (Next daría un error al compilar). Segundo, aunque la convirtieras en Client Component, useFormStatus lee el estado del <form> que está por encima del componente que lo llama; en el componente que renderiza el <form> no hay ninguno por encima, y pending sería siempre false.
Ejercicio 3: Borrar con confirmación y error por fila
Añade a cada fila de la lista de departamentos un botón “Borrar” que pida confirmación, se desactive mientras se borra y, si la API responde 409 (el departamento tiene empleados), muestre el mensaje de la API junto al botón. Si se borra, la fila debe desaparecer sin recargar.
Ver solución
Es el BotonBorrar del apartado 9.1 con la acción borrarDepartamento. Las piezas importantes:
// app/departamentos/acciones.ts
export async function borrarDepartamento(deptId: number) {
const respuesta = await escribirEnApi(`/departamentos/${deptId}`, 'DELETE')
if (!respuesta.ok) {
return { ok: false as const, mensaje: respuesta.mensaje }
}
updateTag('departamentos')
return { ok: true as const, mensaje: `Departamento ${deptId} borrado` }
}
// app/departamentos/boton-borrar.tsx (núcleo)
const [pendiente, startTransition] = useTransition()
const [error, setError] = useState<string | null>(null)
function borrar() {
if (!confirm(`¿Borrar el departamento ${deptId}?`)) return
startTransition(async () => {
const resultado = await borrarDepartamento(deptId)
setError(resultado.ok ? null : resultado.mensaje)
})
}
Resultado comprobado: en DIRECCIÓN (10) aparece “No se puede borrar un departamento con empleados” (figura 8); en un departamento recién creado sin empleados, la fila desaparece.
- La fila desaparece sin código extra en el cliente:
updateTaghace que la respuesta de la acción incluya la página renderizada de nuevo, y React la aplica. - El 409 es un error esperado: se devuelve, no se lanza. Si se lanzara, en producción el usuario vería un error genérico en
error.tsxen lugar del mensaje de la API. as constenokhace que TypeScript distinga los dos casos (ok: trueyok: false) al leerresultado.
Ejercicio 4: Editar con bind y volver a la lista
Haz la página /departamentos/[id]/editar reutilizando el FormularioDepartamento del apartado 6. El identificador debe llegar a la acción con bind, no con un campo oculto. Al guardar, vuelve a la lista con un aviso “Cambios guardados en el departamento N”. Si el identificador no existe, muestra la página 404.
Ver solución
La acción guardarDepartamento y la página completa están en el apartado 8. Los puntos que hay que entender:
export async function guardarDepartamento(
deptId: number, // fijado con bind
_anterior: EstadoFormulario, // lo pasa useActionState
formData: FormData, // lo pasa el formulario
): Promise<EstadoFormulario> {
// ...
}
const guardarEste = guardarDepartamento.bind(null, departamento.deptId)
<FormularioDepartamento accion={guardarEste} ... />
- Orden de los argumentos.
bindpone sus argumentos delante.useActionStateañade el estado anterior, y el formulario elFormData. Por eso la firma es(deptId, anterior, formData). - Se pasa como prop de servidor a cliente.
guardarEstees una acción ligada, serializable: viaja como referencia más el argumento30(lo viste en el campo$ACTION_1:1del apartado 6.4). notFound()siobtenerDepartamentodevuelvenull.- El
if (!Number.isInteger(deptId))de la acción no es paranoia: el argumento debindva en claro en el HTML, y un usuario puede cambiarlo. <Suspense>alrededor del editor, porque concacheComponentsparamses un dato de la petición.
Resultado comprobado: tras cambiar la localidad de VENTAS (20) a Barcelona, la URL final es /departamentos?guardado=20 y la fila muestra Barcelona (figura 7).
Ejercicio 5: Cancelar un pedido de forma optimista
En la lista de pedidos, añade un botón “Cancelar” para los pedidos PENDIENTE que use la misma acción cambiarEstado y la misma interfaz optimista. Comprueba qué pasa si la acción falla: haz que la acción devuelva { ok: false, mensaje: 'Prueba de error' } antes de llamar a la API y observa la fila.
Ver solución
El botón ya está en el ListaPedidos del apartado 10:
{p.estado === 'PENDIENTE' && (
<button className="border border-red-700 text-red-700 px-3 py-1 rounded" onClick={() => marcar(p.pedidoId, 'CANCELADO')}>
Cancelar
</button>
)}
Reutiliza marcar, que aplica el cambio optimista dentro de startTransition y llama a la acción.
Con el error forzado:
export async function cambiarEstado(pedidoId: number, estado: Pedido['estado']) {
return { ok: false, mensaje: 'Prueba de error' } // temporal
// ...
}
La fila pasa a “CANCELADO (guardando)” al instante y, cuando la acción responde, vuelve sola a PENDIENTE y aparece el aviso rojo “Prueba de error”. No hay que escribir código para deshacer: al terminar la transición, useOptimistic descarta el valor provisional y muestra otra vez pedidos, que no ha cambiado porque la acción no llamó a updateTag.
Recuerda quitar la línea temporal: el editor marca en gris el código que hay detrás porque es inalcanzable.
Ejercicio 6: Atacar tu propia acción y blindarla
- Abre las herramientas del navegador, pestaña Red, y pulsa “Marcar enviado” en un pedido. Copia el valor de la cabecera
Next-Action. - Con
curl, llama a la acción para cancelar otro pedido. - Llámala ahora con
--data '["5022; DROP",{"x":1}]'. ¿Qué pasaría sicambiarEstadono validara? - Repite la llamada del paso 2 con
-H "Origin: http://otro.example". ¿Qué responde?
Ver solución
Las órdenes y las respuestas reales están en el apartado 13. Resumen:
| Prueba | Respuesta | Efecto en la API |
|---|---|---|
| Mismo origen, datos válidos | 200, {"ok":true,"mensaje":"Pedido 5023: CANCELADO"} | El pedido se cancela |
| Datos basura | 200, {"ok":false,"mensaje":"Datos no válidos"} | Ninguno |
| Origen ajeno | 500, E{"digest":"..."}, y en el servidor “does not match origin header… Aborting the action.” | Ninguno |
Sin la validación, pedidoId sería "5022; DROP" y estado un objeto. Con la API EMPRESA, el PATCH iría a /pedidos/5022; DROP y fallaría con un 404, así que aquí no pasaría nada grave, pero con una base de datos y una consulta mal construida, o con una API más permisiva, ese hueco es el principio de un problema serio. La regla es: los tipos de TypeScript no existen en tiempo de ejecución; valida todo lo que entra en una acción.
Lo que falta para que la acción sea segura de verdad es comprobar quién la llama. Ahora mismo cualquiera con acceso a la web puede cancelar pedidos, porque la acción usa la cuenta de servicio. En el módulo 17 añadiremos la sesión y la comprobación de rol al principio de cada acción.
Ejercicio 7: El redirect que no redirige
Un compañero ha escrito esta acción y dice que “el redirect de Next no funciona”:
export async function guardarYVolver(deptId: number, _: Resultado, formData: FormData): Promise<Resultado> {
try {
const r = await escribirEnApi(`/departamentos/${deptId}`, 'PUT', { nombre: formData.get('nombre') })
if (!r.ok) return { mensaje: r.mensaje }
updateTag('departamentos')
redirect('/departamentos')
} catch {
return { mensaje: 'Error de conexión con la API' }
}
}
Al guardar, siempre aparece “Error de conexión con la API” y no se sale de la página, aunque el departamento sí se guarda. Explica por qué y corrígelo de dos formas.
Ver solución
redirect funciona lanzando una excepción (NEXT_REDIRECT). El catch la captura como si fuera un fallo de la API y devuelve el mensaje de error. El departamento se ha guardado (el PUT ya se hizo), pero el usuario ve un error y se queda en la página. Lo comprobamos en el apartado 8.1: el servidor registró capturado: NEXT_REDIRECT.
Forma 1, sacar redirect del try (la recomendada):
export async function guardarYVolver(deptId: number, _: Resultado, formData: FormData): Promise<Resultado> {
try {
const r = await escribirEnApi(`/departamentos/${deptId}`, 'PUT', { nombre: formData.get('nombre') })
if (!r.ok) return { mensaje: r.mensaje }
} catch {
return { mensaje: 'Error de conexión con la API' }
}
updateTag('departamentos')
redirect('/departamentos')
}
Forma 2, volver a lanzar los errores de Next:
import { redirect, unstable_rethrow } from 'next/navigation'
// ...
} catch (error) {
unstable_rethrow(error)
return { mensaje: 'Error de conexión con la API' }
}
La primera es más clara: dentro del try solo queda lo que de verdad puede fallar por la red. La segunda es útil cuando el try envuelve mucho código que puede llamar a redirect, notFound o similares en distintos puntos.
Ejercicio 8: La misma mutación en Next.js 15
Tu equipo sigue en Next.js 15.5. Escribe la lista de departamentos con alta rápida para esa versión: sin 'use cache', sin updateTag y sin refresh. Añade además un botón “Refrescar” que vuelva a pintar la página.
Ver solución
// app/departamentos/page.tsx (Next.js 15)
import { crearRapido, refrescarPagina } from './acciones'
const API = process.env.API_URL ?? 'http://localhost:3000/api'
export default async function PaginaDepartamentos() {
const respuesta = await fetch(`${API}/departamentos?tamano=100`, {
next: { tags: ['departamentos'] }, // la etiqueta que invalida la acción
cache: 'force-cache',
})
const { contenido } = (await respuesta.json()) as { contenido: { deptId: number; nombre: string }[] }
return (
<main>
<ul id="lista">{contenido.map((d) => <li key={d.deptId}>{d.deptId} {d.nombre}</li>)}</ul>
<form action={crearRapido}>
<input name="nombre" />
<input name="localidad" />
<button>Crear</button>
<button formAction={refrescarPagina}>Refrescar</button>
</form>
</main>
)
}
// app/departamentos/acciones.ts (Next.js 15)
'use server'
import { revalidatePath, revalidateTag } from 'next/cache'
const API = process.env.API_URL ?? 'http://localhost:3000/api'
async function obtenerToken() {
const r = await fetch(`${API}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: process.env.API_EMAIL, password: process.env.API_PASSWORD }),
})
return (await r.json()).token as string
}
export async function crearRapido(formData: FormData) {
const nombre = String(formData.get('nombre') ?? '').trim()
if (!nombre) return
await fetch(`${API}/departamentos`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${await obtenerToken()}` },
body: JSON.stringify({ nombre, localidad: formData.get('localidad') || null }),
})
revalidateTag('departamentos') // en Next.js 15: un solo argumento, caduca al momento (como updateTag)
}
export async function refrescarPagina() {
revalidatePath('/departamentos') // en Next.js 15 no hay refresh(): se vuelve a renderizar la ruta
}
Comprobado en el laboratorio con create-next-app@15.5.28 y next build --turbopack: la ruta compila como ○ /departamentos, y al crear “AUDITORIA” la lista lo muestra inmediatamente (72 CALIDAD | 73 AUDITORIA).
| Next.js 16 | Next.js 15 |
|---|---|
'use cache' + cacheTag('departamentos') | fetch(url, { next: { tags: ['departamentos'] }, cache: 'force-cache' }) |
updateTag('departamentos') | revalidateTag('departamentos') |
revalidateTag('departamentos', 'max') | No hay equivalente |
refresh() | revalidatePath('/ruta') en la acción, o router.refresh() en el cliente |
Todo lo demás de este módulo ('use server', useActionState, useFormStatus, useOptimistic, redirect, bind, la seguridad) funciona igual en Next.js 15, que trae React 19.1.
Ejercicio 9 (propuesto): Varias escrituras en paralelo
Haz una acción crearVarios(nombres: string[]) que cree varios departamentos a la vez. Mide cuánto tarda creando tres con tres llamadas a una acción desde el cliente (con Promise.all) y con una sola acción que haga el Promise.all en el servidor. Explica la diferencia con lo visto en el apartado 9.2. Pista: pon RETARDO=500 al arrancar la API para que se note.
Ejercicio 10 (propuesto): Alta de productos
Crea /productos/nuevo con un formulario (nombre, precio, stock, categoría en un <select> cargado de /api/categorias) que use useActionState, muestre errores por campo de tu validación y de la API (422), conserve lo escrito si hay error, desactive el botón con useFormStatus mientras se envía y, al terminar, redirija al detalle del producto creado. Las lecturas de productos deben llevar la etiqueta productos, y la acción debe invalidarla.
Resumen
| Concepto | API | Idea clave |
|---|---|---|
| Server Action | 'use server' | Función asíncrona del servidor que el cliente llama con un POST y un identificador |
| Fichero de acciones | 'use server' en la primera línea | Todas las exportaciones son endpoints; solo funciones async |
| Acción en línea | 'use server' dentro de la función | Solo en Server Components; puede usar variables del componente (cifradas) |
| Formulario | <form action={accion}> | Recibe FormData; funciona sin JavaScript |
| Varios botones | <button formAction={otra}> | Cada botón, su acción |
| Argumentos extra | accion.bind(null, id) | Se colocan delante; viajan en claro |
| Leer lo que escribes | updateTag(tag) 🆕 16.0 | Caduca la etiqueta y la respuesta trae datos frescos |
| Refresco en segundo plano | revalidateTag(tag, perfil) 🔄 16.0 | La respuesta no espera los datos nuevos |
| Por ruta | revalidatePath(ruta) | Invalida lo que renderizó esa ruta |
| Volver a pintar | refresh() 🆕 16.0 | Re-renderiza sin tocar la caché |
| Redirigir | redirect(url) | Lanza; revalida antes; fuera del try o con unstable_rethrow |
| Estado del formulario | useActionState(accion, inicial) | La acción recibe (anterior, formData) y devuelve el nuevo estado |
| Estado de envío | useFormStatus() | Solo en un hijo del <form>; da pending y data |
| Interfaz optimista | useOptimistic(real, reductora) | Valor provisional durante la transición; vuelve solo al real |
| Desde eventos | startTransition(async () => await accion()) | Las acciones se envían de una en una |
| Errores esperados | return { ok: false, mensaje } | Los muestra el formulario |
| Errores inesperados | throw → error.tsx | En producción, mensaje genérico y digest |
| Seguridad | serverActions.allowedOrigins, bodySizeLimit | Next comprueba origen y tamaño; tú, sesión, permisos y datos |