🧱 Full Stack 🎨 Frontend

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.

📅 14 de abril de 2026 ⏱️ 10 min de lectura ✍️ Equipo ProgramacionWebs

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 routeServer Action
ContratoExplícito: URL + método + payloadImplícito: firma de la función
Invocación desde el clientefetch() manualLlamada directa o <form action>
Progressive enhancementNo, por defectoSí, con <form> + useActionState
Reusable desde fuera de la appSí, es una URL pública normalTécnicamente sí (es un POST), pero no está pensada para ello
Visibilidad como superficie de ataqueAlta (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 Origin de la petición contra el Host, 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 en allowedOrigins.
  • 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.

Compartir