Server Actions y el nuevo modelo full-stack de React
React difumina la frontera entre cliente y servidor con las Server Actions. Cómo funcionan, cuándo sustituyen a una API route y por qué tratarlas como un endpoint público no es opcional.
Durante años, construir una funcionalidad full-stack en React implicaba dos proyectos mentales separados: un componente de cliente que hacía fetch a una ruta, y una API route en el servidor que recibía esa petición, la validaba y devolvía JSON. Dos archivos, dos contratos implícitos entre ellos, y la responsabilidad de mantenerlos sincronizados cada vez que cambiaba algo en cualquiera de los dos lados. Las Server Actions —oficialmente “Server Functions” en la documentación de React desde la unificación de nomenclatura— colapsan ese modelo en una sola pieza: una función que vive en el servidor pero que un componente de cliente puede invocar como si fuera una función local.
No es una capa de azúcar sintáctico sobre fetch. Es un cambio real en dónde vive la lógica de mutación de una aplicación full-stack, y trae consigo un modelo de seguridad distinto que conviene entender antes de escribir la primera acción en producción.
Qué es exactamente una Server Function
Una Server Function es una función asíncrona marcada con la directiva 'use server' que se ejecuta exclusivamente en el servidor, aunque se importe y se llame desde un componente cliente. React se encarga de generar, en el bundle de cliente, una referencia a esa función en lugar de su implementación real: en tiempo de ejecución, invocarla dispara una petición de red hacia el servidor, se ejecuta allí, y el resultado vuelve al cliente.
// app/notes/actions.ts
'use server'
import { db } from '@/lib/db'
import { auth } from '@/lib/auth'
export async function createNote(formData: FormData) {
const session = await auth()
if (!session?.user) throw new Error('No autenticado')
const content = String(formData.get('content') ?? '').trim()
if (!content) throw new Error('La nota no puede estar vacía')
await db.note.create({
data: { content, authorId: session.user.id },
})
}
// app/notes/new-note-form.tsx
'use client'
import { createNote } from './actions'
export function NewNoteForm() {
return (
<form action={createNote}>
<textarea name="content" required />
<button type="submit">Guardar</button>
</form>
)
}
No hay ruta que definir, ni cliente HTTP que configurar, ni tipo de respuesta que serializar a mano: el formulario apunta directamente a la función, y TypeScript conoce la forma exacta de sus parámetros y su valor de retorno en ambos lados. Esa inferencia de tipo compartida sin generación de código es la misma idea de fondo que popularizó el T3 Stack con tRPC, aplicada aquí directamente dentro del modelo de componentes de React en lugar de como una capa de API independiente.
Para formularios que necesitan reaccionar al resultado de la acción —mostrar un error, deshabilitar el botón mientras se envía— React ofrece useActionState, que además habilita progressive enhancement: el formulario sigue funcionando como un <form> normal si JavaScript todavía no ha cargado.
'use client'
import { useActionState } from 'react'
import { createNote } from './actions'
const initialState = { error: null as string | null }
export function NewNoteForm() {
const [state, formAction, isPending] = useActionState(async (_prev, formData: FormData) => {
try {
await createNote(formData)
return { error: null }
} catch (err) {
return { error: (err as Error).message }
}
}, initialState)
return (
<form action={formAction}>
<textarea name="content" disabled={isPending} required />
<button type="submit" disabled={isPending}>Guardar</button>
{state.error && <p role="alert">{state.error}</p>}
</form>
)
}
El cambio de modelo mental frente a las API routes
Con una API route tradicional, el contrato entre cliente y servidor es explícito y visible: una URL, un verbo HTTP, un cuerpo de petición y una respuesta que hay que tipar manualmente (o generar con OpenAPI, o inferir con algo como tRPC). Ese contrato es una frontera deliberada, y precisamente por serlo obliga a pensar en la API como una superficie pública desde el primer día.
Las Server Actions invierten el orden: escribes primero la función como si fuera código de servidor normal, y React construye el endpoint por debajo sin que lo veas. Esto elimina fricción real —nadie extraña escribir handlers repetitivos para cada mutación— pero también elimina la señal visual que recordaba a cada desarrollador “esto es una puerta de entrada pública a mi sistema”. Ese olvido es la causa raíz de la mayoría de errores de seguridad que aparecen con este patrón, y merece una sección propia.
| API route | Server Action | |
|---|---|---|
| Contrato | Explícito: URL + método + payload | Implícito: firma de la función |
| Invocación desde el cliente | fetch() manual | Llamada directa o <form action> |
| Progressive enhancement | No, por defecto | Sí, con <form> + useActionState |
| Reusable desde fuera de la app | Sí, es una URL pública normal | Técnicamente sí (es un POST), pero no está pensada para ello |
| Visibilidad como superficie de ataque | Alta (se ve en el código de rutas) | Baja si no se piensa activamente en ello |
Cuándo usarlas y cuándo no
El propio equipo de Next.js resume el criterio con claridad: si el usuario está enviando un formulario o pulsando un botón que muta datos propios de tu aplicación, una Server Action debería ser la opción por defecto. Mantén una API route cuando necesites un endpoint invocable desde fuera de tu árbol de componentes React: un webhook de un proveedor externo, una integración de servidor a servidor, un cliente móvil nativo que no comparte el bundle de React, o cuando necesitas semántica HTTP concreta (códigos de estado específicos, cabeceras de caché personalizadas, streaming manual).
El modelo de seguridad: tratar cada acción como un endpoint público
Esta es la idea que más equipos pasan por alto y la que más incidentes produce: una Server Action es, en la práctica, un endpoint HTTP público. El hecho de que solo aparezca invocada desde un formulario oculto tras una comprobación de sesión en el componente no impide que cualquiera pueda construir manualmente la misma petición POST y enviarla directamente, saltándose por completo tu interfaz.
La propia documentación de Next.js lo dice sin rodeos: la comprobación de si renderizar o no un formulario en la interfaz no es una frontera de seguridad, porque las peticiones se pueden enviar sin pasar por esa interfaz en absoluto. Esto significa que toda Server Action que mute datos necesita, dentro de su propio cuerpo, autenticación y autorización explícitas — nunca delegadas al hecho de que el botón que la dispara “solo aparece” para usuarios logueados.
// MAL: el cliente envía el objeto completo, incluido su id y su dueño
export async function completarTareaInseguro(item: Item) {
await db.item.update({ where: { id: item.id }, data: { completed: true } })
}
// BIEN: el cliente solo dice QUÉ cambiar; el servidor decide sobre QUÉ FILA
// a partir de la sesión, no de lo que le llega en el payload
export async function completarTarea(itemId: string) {
const session = await auth()
if (!session?.user) return
const item = await db.item.findFirst({
where: { id: itemId, ownerId: session.user.id },
})
if (!item) return // no existe, o no es del usuario: mismo resultado hacia fuera
await db.item.update({ where: { id: item.id }, data: { completed: true } })
}
La validación de esquema con Zod o similar comprueba que el payload tiene la forma correcta, no que el usuario tenga derecho a modificar esa fila concreta. Un objeto perfectamente válido según el esquema puede seguir apuntando a un recurso ajeno. Por eso la identidad del usuario siempre debe derivarse de la sesión del servidor, nunca del propio payload que envía el cliente — el mismo principio que rige la autenticación moderna con OAuth y passkeys en cualquier backend.
Next.js añade protecciones a nivel de framework que conviene conocer mejor que dar por hecho:
- Comprobación de origen (CSRF). Compara la cabecera
Originde la petición contra elHost, rechazando peticiones que no coincidan. Si tu app vive detrás de un proxy o CDN con un dominio distinto, hay que declararlo explícitamente enallowedOrigins. - Eliminación de código muerto. Las Server Functions que ningún componente cliente termina usando se eliminan del bundle, así que no quedan expuestas como endpoint accesible sin motivo.
- IDs de acción cifrados. La referencia que el cliente usa para invocar la acción no revela su implementación ni su ruta de archivo.
Ninguna de estas protecciones sustituye la comprobación de autorización dentro de la acción. Son una red de seguridad adicional, no la frontera de seguridad en sí. De hecho, en enero de 2026 se publicó y corrigió una vulnerabilidad real en el modo RSC de React Router que permitía que una Server Action se ejecutara antes de que terminara la validación CSRF, dejando una ventana donde la petición completaba su efecto aunque la respuesta final indicara un error 400 — un recordatorio de que estas protecciones de framework son código con sus propios bugs, no leyes físicas.
Revalidación: la otra mitad del modelo
Una Server Action que muta datos normalmente necesita decirle al framework qué partes de la caché quedaron obsoletas. En Next.js esto se resuelve con funciones como revalidatePath o revalidateTag, invocadas dentro de la propia acción tras completar la escritura:
'use server'
import { revalidatePath } from 'next/cache'
export async function createPost(formData: FormData) {
const session = await auth()
if (!session?.user) throw new Error('No autenticado')
await db.post.create({
data: { title: String(formData.get('title')), authorId: session.user.id },
})
revalidatePath('/posts')
}
Cuando la acción dispara esta revalidación inmediata, la respuesta que vuelve al cliente incluye en la misma petición tanto el resultado de la acción como el árbol ya re-renderizado de la ruta actual — no hace falta una segunda llamada de red para reflejar el cambio en pantalla. Es una optimización real de rendimiento, pero también otro motivo para no perder de vista qué datos exactos viajan en esa respuesta.
Encaja de forma distinta según el framework
Astro adopta el mismo concepto con sus propias Actions, pensadas para un modelo de islas donde la mayor parte de la página se sirve estática y solo fragmentos concretos son interactivos — algo que conviene entender junto con cómo funciona realmente la arquitectura de islas antes de decidir si tu proyecto encaja mejor en ese modelo o en el de Next.js con App Router, una comparación que desarrollamos en detalle en Astro vs Next.js: cómo elegir en 2026.
Lo que no cambia entre frameworks es el principio de fondo: en cuanto una función marcada para ejecutarse en el servidor queda alcanzable desde el cliente, es una superficie de red pública con las mismas obligaciones de autenticación, autorización y validación que cualquier endpoint de una API REST tradicional. El modelo mental cambia; las reglas de seguridad, no.
Artículos relacionados
Astro vs Next.js en 2026: cómo elegir el framework correcto para tu proyecto
Astro y Next.js no compiten por lo mismo. Uno está diseñado para que el contenido llegue rápido; el otro, para que una aplicación con estado se comporte bien. Una guía de decisión basada en el modelo mental de cada framework, no en una tabla de features.
El T3 Stack y el auge de los starters full-stack tipados de extremo a extremo
tRPC eliminó la frontera donde el tipado se rompía entre frontend y backend. Repasamos qué compone el T3 Stack, por qué sigue vivo en 2026 y cuándo conviene usarlo o evitarlo.
Cómo construir un agente de IA con Next.js y MCP
Una aplicación Next.js completa que habla con un servidor MCP propio: arquitectura, código real y las decisiones que cambian entre desarrollo local y producción.