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 POST y 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 FormData y pasar argumentos extra con bind
  • Mantener la pantalla al día después de una mutación con updateTag, revalidateTag, revalidatePath y refresh, sabiendo cuál elegir
  • Redirigir al terminar con redirect, y evitar la trampa de llamarlo dentro de un try
  • Devolver resultados al formulario con useActionState, mostrar el estado de envío con useFormStatus y adelantar el resultado con useOptimistic
  • 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, useOptimistic y 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:

  1. Un endpoint en un servidor aparte (POST /api/departamentos).
  2. Un fetch en el navegador que lo llama, con su JSON.stringify, sus cabeceras y su gestión de errores.
  3. 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 de startTransition. 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 POST al 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.

Diagrama de ida y vuelta: el navegador envía un POST con la cabecera Next-Action y el cuerpo multipart; el servidor comprueba el origen, busca la acción, la ejecuta, invalida la caché con updateTag, vuelve a renderizar la ruta y devuelve en una sola respuesta text/x-component el valor y el nuevo RSC Payload

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 POST con curl, 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 (sin export) no lo son. Para marcar un módulo como “solo servidor, nunca llamable”, usa import '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:

  • escribirEnApi no 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.ts lleva server-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' y cacheTag solo existen como experimentales (experimental.useCache y unstable_cacheTag). Si usas Next.js 15: etiqueta las lecturas con la opción de fetch next: { tags: ['departamentos'] } (y cache: 'force-cache'), e invalídalas en la acción con revalidateTag('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 build necesita 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", no 120000. Convierte tú los tipos.
  • leerCampos y aCuerpo no 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 onSubmit no hace nada; uno con action se 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:

Página de departamentos con el JavaScript desactivado: la tabla muestra FORMACIÓN (Sevilla) y CALIDAD (Zaragoza) recién creados, y debajo el formulario de alta rápida con los botones Crear y Crear (PRG)

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> con redirect. 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 de cacheLife ('max', 'hours'…), y pasa a ser stale-while-revalidate. Si usas Next.js 15: llama a revalidateTag('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 revalidatePath con la ruta actual dentro de la acción, o a router.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

Árbol de decisión: si los datos están en una caché con etiqueta y el usuario debe ver su cambio en esta respuesta, updateTag; si no hace falta, revalidateTag con perfil; si no hay etiqueta y afecta a una ruta, revalidatePath; si solo es la pantalla actual, refresh

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, revalidatePath o refresh (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)
  • accion es una función (estadoAnterior, formData) => nuevoEstado. Fíjate: con useActionState, la acción recibe primero el estado anterior y después el FormData.
  • estado empieza valiendo estadoInicial y, después de cada envío, vale lo que devolvió la acción.
  • formAction es la acción “envuelta” que pones en <form action={formAction}>.
  • pendiente es true mientras 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:

Formulario Nuevo departamento con el aviso rojo Revisa los campos marcados; el campo Nombre vacío con el error Escribe un nombre, Localidad con Valencia, y Presupuesto con mucho y el error El presupuesto debe ser un número

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:

Formulario Nuevo departamento con el aviso verde Departamento 71 creado: I+D y los tres campos 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:

  1. La acción devuelve en valores lo que recibió (si hubo error) o cadenas vacías (si salió bien).
  2. Cada <input> usa defaultValue={valor}.
  3. El key={valor} obliga a React a volver a crear el <input> cuando cambia el valor devuelto, para que tome el nuevo defaultValue. Sin la key, defaultValue solo 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 useActionState viaja 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,{&quot;ok&quot;:false,&quot;mensaje&quot;:&quot;&quot;,...,&quot;valores&quot;:{&quot;nombre&quot;:&quot;INFORMÁTICA&quot;,...}}]">

El 30 es el deptId fijado con bind, 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:

Formulario durante el envío: el botón aparece desactivado con el texto Guardando «I+D»...; los mensajes de error del intento anterior todavía se ven

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: useFormStatus solo funciona en un componente hijo del <form>. Si lo llamas en el mismo componente que renderiza el <form>, siempre devuelve pending: false, porque busca un formulario por encima de él. Es el error más frecuente con este hook. Por eso BotonEnviar es 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

Lista de departamentos con el aviso verde Cambios guardados en el departamento 20, y la fila 20 VENTAS con localidad 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:

  1. Revalida antes de redirigir. redirect lanza una excepción especial; el código que va detrás no se ejecuta nunca. Si pones updateTag después, la lista no se actualiza.
  2. El tipo de retorno no importa. redirect devuelve never para TypeScript, así que la acción cumple su tipo Promise<EstadoFormulario> aunque el camino feliz no devuelva nada.
  3. 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 redirect fuera del try. Dentro del try solo va lo que puede fallar de verdad (la llamada a la API).
  • Si no puedes: llama a unstable_rethrow(error) al principio del catch. Vuelve a lanzar los errores internos de Next (redirect, notFound, forbidden…) y deja pasar los demás. A pesar del prefijo unstable_, existe tanto en Next.js 16 como en 15.

💡 Consejo: Nuestro escribirEnApi devuelve los errores en lugar de lanzarlos precisamente para no necesitar try/catch en las acciones. Menos try, menos ocasiones de tragarse un redirect.


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:

Lista de departamentos: bajo el botón Borrar de la fila 10 DIRECCIÓN aparece en rojo No se puede borrar un departamento con empleados; la tabla incluye la fila 71 I+D recién creada

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.all en 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)
  • valorReal es 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, optimista vale el provisional. Cuando la transición termina, React lo descarta y vuelve a valorReal, que para entonces ya es el dato nuevo (porque la acción hizo updateTag y 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:

Lista de seis pedidos: el primero, 5024 Gestoría Delta, aparece atenuado con el estado ENVIADO (guardando) y sin botones; 5023 y 5022 siguen PENDIENTE con los botones Marcar enviado y Cancelar

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:

  • aplicarCambio tiene que llamarse dentro de una transición (startTransition o 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: true que 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:

Página con el recuadro rojo No se ha podido completar la operación; el mensaje es Minified React error #441 con un enlace a react.dev, el código para soporte 2782986613 y el enlace Reintentar

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="&quot;offd9QdvAQnKIY6e8EnX0O2m4aBkRU9MAvdEL4EtEfNa5/d8/EM5VhxW5sh/VnPB0WkorrT8R/oFSrUDFIkul+vTpugVBIxoDm2Qr16VD5g9b9Gl9h6HSiCr5zpiPsrujLQI&quot;"/>
<input type="hidden" name="$ACTION_0:1" value="[&quot;$@2&quot;]"/>
<input type="hidden" name="$ACTION_0:0" value="{&quot;id&quot;:&quot;4022e5ed7a8ecfb22423cb5dfb97515df4301df26c&quot;,&quot;bound&quot;:&quot;$@1&quot;}"/>

<!-- Formulario 2: acción con bind -->
<input type="hidden" name="$ACTION_REF_1"/>
<input type="hidden" name="$ACTION_1:1" value="[&quot;VALOR-LIGADO-4242&quot;]"/>
<input type="hidden" name="$ACTION_1:0" value="{&quot;id&quot;:&quot;40ebdb90bdbb93147cb1046d2bdc87074f737f7e9c&quot;,&quot;bound&quot;:&quot;$@1&quot;}"/>

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 bind no 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:

  1. 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.)
  2. Valida los argumentos y el FormData. Tipos, rangos, formatos. Zod en el módulo 11.
  3. Recibe identificadores, no objetos completos. Si la acción recibe un Pedido entero del cliente, el atacante decide su contenido. Recibe pedidoId y vuelve a leer el pedido en el servidor.
  4. 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.
  5. No confíes en lo oculto. Campos hidden, argumentos de bind, 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 en lib/. Cuando en el módulo 16 cambiemos la API por una base de datos, las acciones apenas cambiarán: solo escribirEnApi por 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.
  • updateTag va antes de redirect, porque redirect lanza 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 required del <input> es una ayuda en el navegador; la comprobación de verdad es el if (!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: updateTag hace 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.tsx en lugar del mensaje de la API.
  • as const en ok hace que TypeScript distinga los dos casos (ok: true y ok: false) al leer resultado.

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. bind pone sus argumentos delante. useActionState añade el estado anterior, y el formulario el FormData. Por eso la firma es (deptId, anterior, formData).
  • Se pasa como prop de servidor a cliente. guardarEste es una acción ligada, serializable: viaja como referencia más el argumento 30 (lo viste en el campo $ACTION_1:1 del apartado 6.4).
  • notFound() si obtenerDepartamento devuelve null.
  • El if (!Number.isInteger(deptId)) de la acción no es paranoia: el argumento de bind va en claro en el HTML, y un usuario puede cambiarlo.
  • <Suspense> alrededor del editor, porque con cacheComponents params es 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

  1. Abre las herramientas del navegador, pestaña Red, y pulsa “Marcar enviado” en un pedido. Copia el valor de la cabecera Next-Action.
  2. Con curl, llama a la acción para cancelar otro pedido.
  3. Llámala ahora con --data '["5022; DROP",{"x":1}]'. ¿Qué pasaría si cambiarEstado no validara?
  4. 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