Server Actions en Next.js 15: Guía Completa con Formularios, Validación y Seguridad

Domina los Server Actions en Next.js 15: aprende a construir formularios con useActionState, validar con Zod, implementar actualizaciones optimistas y asegurar tus acciones con next-safe-action y patrones de seguridad avanzados.

Introducción: El cambio de paradigma que estabas esperando

Si llevas tiempo desarrollando con Next.js, seguramente recuerdas la época en la que cualquier interacción con el servidor requería crear un archivo en /api, configurar un endpoint, manejar el método HTTP y luego hacer un fetch desde el cliente. Funcionaba, sí, pero seamos honestos: era tedioso. Con Next.js 15 y React 19, los Server Actions cambian las reglas del juego por completo.

¿Qué son exactamente? En pocas palabras, son funciones asíncronas que se ejecutan exclusivamente en el servidor pero que puedes invocar directamente desde tus componentes de React, tanto del lado del servidor como del cliente. No necesitas crear rutas API intermedias. No necesitas configurar endpoints REST. Y lo mejor de todo: funcionan con formularios HTML nativos gracias al progressive enhancement.

Este cambio de paradigma significa que puedes escribir lógica de servidor — consultas a base de datos, envío de emails, procesamiento de archivos — y vincularla directamente a la interacción del usuario sin esa capa intermedia que siempre generaba fricción. El resultado es un código más limpio, más seguro y, sinceramente, mucho más fácil de mantener.

En esta guía vamos a recorrer todo lo que necesitas saber sobre Server Actions en Next.js 15: desde los fundamentos hasta patrones avanzados, pasando por validación con Zod, actualizaciones optimistas, seguridad y la integración con next-safe-action. Así que, vamos a ello.

Fundamentos de Server Actions

La directiva 'use server'

Todo comienza con una directiva: 'use server'. Esta instrucción le dice a Next.js que la función (o todas las funciones de un archivo) deben ejecutarse únicamente en el servidor. Hay dos formas principales de utilizarla.

Inline dentro de un Server Component: Puedes definir una función asíncrona directamente dentro de un componente del servidor y marcarla con 'use server' al inicio del cuerpo de la función.

// app/page.tsx (Server Component)
export default function PaginaContacto() {
  async function enviarFormulario(formData: FormData) {
    'use server'

    const nombre = formData.get('nombre') as string
    const email = formData.get('email') as string

    // Esta lógica se ejecuta SOLO en el servidor
    await guardarEnBaseDeDatos({ nombre, email })
  }

  return (
    <form action={enviarFormulario}>
      <input name="nombre" type="text" required />
      <input name="email" type="email" required />
      <button type="submit">Enviar</button>
    </form>
  )
}

En archivos dedicados: Esta es, en mi opinión, la alternativa más recomendable para proyectos reales. Colocas 'use server' al inicio de un archivo y todas las funciones exportadas desde ese archivo se convierten automáticamente en Server Actions. Limpio y organizado.

// app/actions/contacto.ts
'use server'

import { db } from '@/lib/database'
import { revalidatePath } from 'next/cache'

export async function enviarContacto(formData: FormData) {
  const nombre = formData.get('nombre') as string
  const email = formData.get('email') as string
  const mensaje = formData.get('mensaje') as string

  await db.contacto.create({
    data: { nombre, email, mensaje }
  })

  revalidatePath('/contacto')
}

export async function eliminarContacto(id: string) {
  await db.contacto.delete({ where: { id } })
  revalidatePath('/admin/contactos')
}

Cómo funcionan por debajo

Cuando defines un Server Action, Next.js hace varias cosas tras bambalinas que conviene entender:

  • Genera un endpoint POST único para cada acción. Cuando el cliente invoca la acción, se realiza una petición POST a ese endpoint con los datos serializados.
  • Los argumentos se serializan usando el protocolo de React (no JSON estándar), lo que permite pasar tipos como FormData, Date y otros objetos especiales.
  • Las closures (variables capturadas del ámbito exterior) también se serializan y se envían al servidor. Esto tiene implicaciones de seguridad importantes que veremos más adelante.
  • El resultado se devuelve al cliente y React puede actualizar la UI de forma reactiva, incluyendo la revalidación del caché si se solicita.

Un detalle que vale la pena resaltar: los Server Actions siempre usan POST. Esto es intencional. Las peticiones POST no se cachean por defecto en los navegadores, lo que las hace ideales para operaciones que modifican datos. Si necesitas lectura de datos, sigue usando Server Components o fetch con las rutas de API.

Server Actions con Formularios

El atributo action y progressive enhancement

Una de las características más elegantes de los Server Actions es que se integran directamente con el atributo action de los formularios HTML. Esto significa que, si JavaScript falla o aún no se ha cargado, el formulario sigue funcionando. Es lo que se conoce como progressive enhancement, y es una victoria enorme para la accesibilidad y la robustez de tu aplicación.

Cuando pasas un Server Action como action de un <form>, React 19 intercepta el envío, serializa los datos como FormData y los envía al servidor. Si el cliente no tiene JavaScript activo, el navegador envía el formulario de forma nativa al endpoint generado por Next.js. Bastante ingenioso, la verdad.

Ejemplo práctico: Formulario de inicio de sesión

Veamos un ejemplo más completo con un formulario de login que maneja errores y estados de forma adecuada:

// app/actions/auth.ts
'use server'

import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
import { verificarCredenciales, generarToken } from '@/lib/auth'

export async function iniciarSesion(
  estadoPrevio: { error: string | null },
  formData: FormData
) {
  const email = formData.get('email') as string
  const password = formData.get('password') as string

  if (!email || !password) {
    return { error: 'Todos los campos son obligatorios' }
  }

  const usuario = await verificarCredenciales(email, password)

  if (!usuario) {
    return { error: 'Credenciales inválidas. Inténtalo de nuevo.' }
  }

  const token = await generarToken(usuario.id)
  const cookieStore = await cookies()
  cookieStore.set('session', token, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',
    maxAge: 60 * 60 * 24 * 7 // 7 días
  })

  redirect('/dashboard')
}

Fíjate en la firma de la función: recibe un estadoPrevio como primer argumento y el FormData como segundo. Esta firma es exactamente la que espera el hook useActionState, que veremos ahora mismo. El estado previo nos permite devolver mensajes de error que se mostrarán al usuario.

useActionState y Gestión de Estado

El hook de React 19 para Server Actions

React 19 introduce useActionState (anteriormente conocido como useFormState en versiones experimentales), un hook diseñado específicamente para gestionar el estado de los Server Actions. Se encarga de tres cosas fundamentales: mantener el estado actual de la acción, proporcionar una versión envuelta de la acción lista para usar en formularios, y exponer un booleano de pending para saber si la acción está en progreso.

Suena abstracto, pero se entiende mucho mejor con código:

// app/login/page.tsx
'use client'

import { useActionState } from 'react'
import { iniciarSesion } from '@/app/actions/auth'

export default function PaginaLogin() {
  const [estado, accionFormulario, isPending] = useActionState(
    iniciarSesion,
    { error: null } // Estado inicial
  )

  return (
    <form action={accionFormulario} className="space-y-4">
      <div>
        <label htmlFor="email">Correo electrónico</label>
        <input
          id="email"
          name="email"
          type="email"
          required
          disabled={isPending}
          className="w-full border rounded px-3 py-2"
        />
      </div>

      <div>
        <label htmlFor="password">Contraseña</label>
        <input
          id="password"
          name="password"
          type="password"
          required
          disabled={isPending}
          className="w-full border rounded px-3 py-2"
        />
      </div>

      {estado.error && (
        <div className="bg-red-50 text-red-600 p-3 rounded">
          {estado.error}
        </div>
      )}

      <button
        type="submit"
        disabled={isPending}
        className="w-full bg-blue-600 text-white py-2 rounded hover:bg-blue-700 disabled:opacity-50"
      >
        {isPending ? 'Iniciando sesión...' : 'Iniciar sesión'}
      </button>
    </form>
  )
}

El hook useActionState devuelve un array con tres elementos:

  1. estado: El valor actual del estado, que se actualiza con lo que retorna el Server Action.
  2. accionFormulario: La función envuelta que debes pasar al atributo action del formulario.
  3. isPending: Un booleano que es true mientras la acción se está ejecutando en el servidor.

Lo bonito de este enfoque es que no necesitas useState, useEffect ni try/catch manuales para gestionar los estados del formulario. Todo queda encapsulado en un patrón limpio y declarativo.

Además, como el estado se devuelve directamente desde el servidor, puedes incluir cualquier información relevante: mensajes de éxito, errores de validación por campo, datos actualizados... la flexibilidad es enorme.

Validación con Zod

Esquemas de validación type-safe

Confiar en que los datos del FormData van a llegar correctamente es un error que no puedes permitirte. Y esto es algo que he visto en demasiados proyectos. Recuerda: los Server Actions son, a todos los efectos, endpoints públicos. Cualquiera podría enviar una petición POST con datos maliciosos. Aquí es donde Zod se convierte en tu mejor aliado.

Zod te permite definir esquemas de validación que no solo validan los datos en tiempo de ejecución, sino que también infieren los tipos de TypeScript automáticamente. Veamos cómo integrarlo:

// app/actions/crear-articulo.ts
'use server'

import { z } from 'zod'
import { db } from '@/lib/database'
import { revalidatePath } from 'next/cache'

const esquemaArticulo = z.object({
  titulo: z
    .string()
    .min(5, 'El título debe tener al menos 5 caracteres')
    .max(200, 'El título no puede superar los 200 caracteres'),
  contenido: z
    .string()
    .min(50, 'El contenido debe tener al menos 50 caracteres'),
  categoria: z.enum(['tecnologia', 'diseño', 'negocios'], {
    errorMap: () => ({ message: 'Selecciona una categoría válida' })
  }),
  publicado: z.coerce.boolean().default(false)
})

type EstadoFormulario = {
  errores: Record<string, string[]> | null
  mensaje: string | null
  exito: boolean
}

export async function crearArticulo(
  estadoPrevio: EstadoFormulario,
  formData: FormData
): Promise<EstadoFormulario> {
  const datosRaw = {
    titulo: formData.get('titulo'),
    contenido: formData.get('contenido'),
    categoria: formData.get('categoria'),
    publicado: formData.get('publicado')
  }

  const resultado = esquemaArticulo.safeParse(datosRaw)

  if (!resultado.success) {
    return {
      errores: resultado.error.flatten().fieldErrors as Record<string, string[]>,
      mensaje: 'Error de validación. Revisa los campos marcados.',
      exito: false
    }
  }

  try {
    await db.articulo.create({
      data: resultado.data
    })
  } catch (error) {
    return {
      errores: null,
      mensaje: 'Error al guardar el artículo. Inténtalo más tarde.',
      exito: false
    }
  }

  revalidatePath('/articulos')

  return {
    errores: null,
    mensaje: 'Artículo creado correctamente.',
    exito: true
  }
}

Mostrando errores de validación por campo

El método safeParse de Zod devuelve los errores estructurados por campo gracias a flatten(). Esto te permite mostrar mensajes de error específicos junto a cada input del formulario, lo cual es una experiencia de usuario muchísimo mejor que un mensaje genérico tipo "algo salió mal".

El patrón es simple: si el campo tiene errores, los muestras debajo del input correspondiente; si no, el campo aparece normal.

Un consejo práctico que me ha ahorrado bastantes dolores de cabeza: usa z.coerce para campos que llegan como strings desde el FormData pero necesitas como otros tipos. Por ejemplo, z.coerce.number() convierte automáticamente "42" a 42, y z.coerce.boolean() maneja los checkboxes correctamente.

Actualizaciones Optimistas con useOptimistic

El patrón useOptimistic

Las actualizaciones optimistas son una técnica de UX donde actualizas la interfaz inmediatamente antes de que el servidor confirme la operación. Si el servidor responde con éxito, perfecto, la UI ya refleja el cambio. Si falla, se deshace la actualización. Esto elimina la latencia percibida y hace que la aplicación se sienta instantánea.

Seguramente has experimentado este patrón sin darte cuenta: cuando le das "like" a algo en redes sociales y el contador sube al instante. Eso es una actualización optimista.

React 19 incluye el hook useOptimistic que se integra perfectamente con Server Actions. Veamos un ejemplo clásico: un sistema de "likes" para artículos.

// app/components/BotonLike.tsx
'use client'

import { useOptimistic, useTransition } from 'react'
import { toggleLike } from '@/app/actions/likes'

type Props = {
  articuloId: string
  likesIniciales: number
  usuarioHaDadoLike: boolean
}

export function BotonLike({
  articuloId,
  likesIniciales,
  usuarioHaDadoLike
}: Props) {
  const [isPending, startTransition] = useTransition()

  const [estadoOptimista, actualizarOptimista] = useOptimistic(
    { likes: likesIniciales, haDadoLike: usuarioHaDadoLike },
    (estadoActual, nuevoLike: boolean) => ({
      likes: nuevoLike
        ? estadoActual.likes + 1
        : estadoActual.likes - 1,
      haDadoLike: nuevoLike
    })
  )

  function handleClick() {
    const nuevoEstado = !estadoOptimista.haDadoLike
    startTransition(async () => {
      actualizarOptimista(nuevoEstado)
      await toggleLike(articuloId)
    })
  }

  return (
    <button
      onClick={handleClick}
      disabled={isPending}
      className={`flex items-center gap-2 px-4 py-2 rounded-full transition-colors ${
        estadoOptimista.haDadoLike
          ? 'bg-red-100 text-red-600'
          : 'bg-gray-100 text-gray-600'
      }`}
    >
      <span>{estadoOptimista.haDadoLike ? '❤️' : '🤍'}</span>
      <span>{estadoOptimista.likes}</span>
    </button>
  )
}
// app/actions/likes.ts
'use server'

import { auth } from '@/lib/auth'
import { db } from '@/lib/database'
import { revalidatePath } from 'next/cache'

export async function toggleLike(articuloId: string) {
  const sesion = await auth()
  if (!sesion?.user) {
    throw new Error('Debes iniciar sesión para dar like')
  }

  const likeExistente = await db.like.findFirst({
    where: {
      articuloId,
      usuarioId: sesion.user.id
    }
  })

  if (likeExistente) {
    await db.like.delete({ where: { id: likeExistente.id } })
  } else {
    await db.like.create({
      data: {
        articuloId,
        usuarioId: sesion.user.id
      }
    })
  }

  revalidatePath('/articulos')
}

Observa cómo funciona el flujo: cuando el usuario hace clic, useOptimistic actualiza la UI al instante con el nuevo estado. Mientras tanto, startTransition ejecuta el Server Action en segundo plano. Si la acción falla, React automáticamente revierte al estado real. Sin código extra de tu parte.

La función reductora que pasas como segundo argumento a useOptimistic define cómo transformar el estado cuando se aplica una actualización optimista. Recibe el estado actual y el valor optimista que envías, y devuelve el nuevo estado que la UI debe mostrar temporalmente.

Revalidación y Caché

revalidatePath y revalidateTag

Uno de los superpoderes de los Server Actions es su integración directa con el sistema de caché de Next.js. Después de una mutación, normalmente necesitas que ciertas páginas o datos se actualicen. Para esto tienes dos herramientas principales.

revalidatePath(ruta) invalida el caché de una ruta específica. Cuando la llamas después de una mutación, la próxima vez que un usuario visite esa ruta, Next.js regenerará la página con datos frescos.

revalidateTag(etiqueta) invalida todas las entradas del caché que fueron etiquetadas con ese tag. Es más granular y flexible, especialmente útil cuando un mismo dato se muestra en múltiples páginas.

// app/actions/productos.ts
'use server'

import { revalidatePath, revalidateTag } from 'next/cache'
import { db } from '@/lib/database'

export async function actualizarPrecio(
  productoId: string,
  nuevoPrecio: number
) {
  await db.producto.update({
    where: { id: productoId },
    data: { precio: nuevoPrecio }
  })

  // Opción 1: Revalidar una ruta específica
  revalidatePath(`/productos/${productoId}`)

  // Opción 2: Revalidar todas las páginas que usan datos con este tag
  revalidateTag('productos')
  revalidateTag(`producto-${productoId}`)
}

// En tu fetch con tags:
// fetch('https://api.example.com/productos', {
//   next: { tags: ['productos'] }
// })

Cuándo usar cada estrategia

Usa revalidatePath cuando sabes exactamente qué página debe actualizarse y la mutación afecta a una sola vista. También funciona bien cuando trabajas con el App Router y quieres invalidar layouts completos (puedes pasar 'layout' como segundo argumento).

Usa revalidateTag cuando los mismos datos aparecen en múltiples ubicaciones o cuando quieres un control más fino sobre qué datos se invalidan sin acoplar tu lógica a rutas específicas. Es ideal para APIs externas donde etiquetas los fetch y luego invalidas por semántica de datos en lugar de por ruta.

Un patrón que he visto funcionar muy bien en proyectos medianos es combinar ambos: revalidar el tag del tipo de dato (para listas y vistas generales) y la ruta específica del detalle (para la página individual). Así garantizas que toda la aplicación refleje los cambios al momento.

next-safe-action: Acciones tipadas y seguras

El cliente de acciones type-safe

Aunque los Server Actions son poderosos por sí solos, el paquete next-safe-action lleva la experiencia al siguiente nivel. Te proporciona un wrapper que añade validación automática con Zod, tipado end-to-end, middleware reutilizable y un manejo de errores consistente. Honestamente, una vez que lo pruebas cuesta volver atrás.

La idea central es crear un "cliente de acciones" que define el pipeline por el que pasan todas tus acciones. Piénsalo como un middleware de Express, pero para Server Actions:

// lib/safe-action.ts
import { createSafeActionClient } from 'next-safe-action'
import { auth } from '@/lib/auth'

// Cliente base: para acciones públicas
export const actionClient = createSafeActionClient({
  handleServerError(error) {
    // Logging centralizado
    console.error('Error en Server Action:', error.message)

    // Nunca exponer errores internos al cliente
    return 'Ha ocurrido un error inesperado. Inténtalo de nuevo.'
  }
})

// Cliente autenticado: requiere sesión activa
export const authActionClient = actionClient.use(async ({ next }) => {
  const sesion = await auth()

  if (!sesion?.user) {
    throw new Error('No autorizado')
  }

  // Pasar datos de contexto a las siguientes acciones
  return next({
    ctx: {
      usuario: sesion.user
    }
  })
})

// Cliente con rate limiting
export const rateLimitedActionClient = authActionClient.use(
  async ({ ctx, next }) => {
    const { usuario } = ctx
    const clave = `action:${usuario.id}`

    const intentos = await obtenerIntentos(clave)
    if (intentos > 10) {
      throw new Error('Demasiadas solicitudes. Espera un momento.')
    }

    await incrementarIntentos(clave)
    return next({ ctx })
  }
)

Definiendo acciones seguras

Una vez que tienes el cliente configurado, defines las acciones de forma declarativa con el esquema de entrada. Es sorprendentemente limpio:

// app/actions/crear-proyecto.ts
'use server'

import { z } from 'zod'
import { authActionClient } from '@/lib/safe-action'
import { db } from '@/lib/database'
import { revalidateTag } from 'next/cache'

const esquemaProyecto = z.object({
  nombre: z.string().min(3).max(100),
  descripcion: z.string().max(500).optional(),
  esPublico: z.boolean().default(false)
})

export const crearProyecto = authActionClient
  .schema(esquemaProyecto)
  .action(async ({ parsedInput, ctx }) => {
    // parsedInput ya está validado y tipado
    // ctx contiene el usuario autenticado del middleware

    const proyecto = await db.proyecto.create({
      data: {
        ...parsedInput,
        propietarioId: ctx.usuario.id
      }
    })

    revalidateTag('proyectos')

    return { proyecto }
  })

El beneficio principal aquí es la composabilidad. Puedes encadenar múltiples middlewares — autenticación, rate limiting, logging, verificación de permisos — y cada acción hereda toda esa lógica sin repetir código. Además, el tipo del resultado y de los errores está completamente inferido, lo que te da autocompletado perfecto tanto en la acción como en el componente que la consume.

Para consumir la acción en el cliente, next-safe-action proporciona hooks como useAction y useOptimisticAction que manejan automáticamente los estados de carga, éxito y error:

// app/components/FormularioProyecto.tsx
'use client'

import { useAction } from 'next-safe-action/hooks'
import { crearProyecto } from '@/app/actions/crear-proyecto'

export function FormularioProyecto() {
  const { execute, result, isExecuting } = useAction(crearProyecto, {
    onSuccess({ data }) {
      // Notificación de éxito, redirección, etc.
      console.log('Proyecto creado:', data?.proyecto)
    },
    onError({ error }) {
      // Manejo centralizado de errores
      console.error('Error:', error.serverError)
    }
  })

  return (
    <form
      onSubmit={(e) => {
        e.preventDefault()
        const formData = new FormData(e.currentTarget)
        execute({
          nombre: formData.get('nombre') as string,
          descripcion: formData.get('descripcion') as string,
          esPublico: formData.get('esPublico') === 'on'
        })
      }}
    >
      <input name="nombre" placeholder="Nombre del proyecto" required />
      <textarea name="descripcion" placeholder="Descripción (opcional)" />
      <label>
        <input name="esPublico" type="checkbox" />
        Proyecto público
      </label>

      {result.validationErrors && (
        <div className="text-red-600">
          {Object.entries(result.validationErrors).map(([campo, errores]) => (
            <p key={campo}>{campo}: {errores?.join(', ')}</p>
          ))}
        </div>
      )}

      <button type="submit" disabled={isExecuting}>
        {isExecuting ? 'Creando...' : 'Crear proyecto'}
      </button>
    </form>
  )
}

Seguridad en Server Actions

Protección CSRF integrada

Un aspecto que preocupa (y con razón) a los desarrolladores es la seguridad. Los Server Actions parecen mágicos, pero al final son endpoints HTTP. La buena noticia es que Next.js incluye varias capas de protección por defecto.

La protección CSRF (Cross-Site Request Forgery) viene integrada. Next.js verifica automáticamente que el encabezado Origin de la petición coincida con el host del servidor. Si no coinciden, la petición se rechaza con un error 403. Esto significa que un sitio malicioso no puede hacer que el navegador de tu usuario envíe formularios a tu Server Action sin que lo detectes.

En Next.js 15 puedes configurar explícitamente los orígenes permitidos en next.config.js:

// next.config.js
module.exports = {
  experimental: {
    serverActions: {
      allowedOrigins: [
        'mi-dominio.com',
        'staging.mi-dominio.com'
      ],
      bodySizeLimit: '2mb'
    }
  }
}

Principios de seguridad fundamentales

Más allá de la protección CSRF, hay varios principios que debes seguir rigurosamente. Puede parecer repetitivo, pero la seguridad es una de esas cosas donde la redundancia es bienvenida:

  • Trata cada Server Action como un endpoint público: Cualquier persona puede enviar una petición POST al endpoint generado. Nunca confíes en que los datos vienen de tu formulario. Valida siempre en el servidor.
  • Verifica la autenticación y autorización: Que un usuario esté logueado no significa que tenga permiso para ejecutar esa acción. Comprueba ambos aspectos dentro de cada Server Action.
  • Cuidado con las closures: Cuando un Server Action captura variables del ámbito exterior (como un ID), esos valores se serializan y viajan al cliente. Un usuario malintencionado podría modificarlos. Siempre revalida en el servidor cualquier dato que venga de una closure.
  • Implementa rate limiting: Sin protección contra abuso, un atacante podría bombardear tus Server Actions con miles de peticiones. Usa middlewares como el que vimos con next-safe-action o soluciones como upstash/ratelimit.
  • Sanitiza las entradas: Además de validar con Zod, asegúrate de sanitizar cualquier dato que vaya a almacenarse o mostrarse para prevenir inyecciones SQL y ataques XSS.
  • No expongas errores internos: Nunca devuelvas mensajes de error que revelen detalles de implementación (nombres de tablas, stack traces, etc.). Usa mensajes genéricos para el usuario y loguea los detalles internamente.

Un patrón robusto es crear una función wrapper que centralice estas verificaciones:

// lib/action-segura.ts
'use server'

import { auth } from '@/lib/auth'
import { headers } from 'next/headers'

export async function verificarAcceso(permisoRequerido?: string) {
  const sesion = await auth()

  if (!sesion?.user) {
    throw new Error('NO_AUTENTICADO')
  }

  if (permisoRequerido) {
    const tienePermiso = sesion.user.permisos?.includes(permisoRequerido)
    if (!tienePermiso) {
      throw new Error('NO_AUTORIZADO')
    }
  }

  return sesion.user
}

// Uso en un Server Action:
export async function eliminarUsuario(usuarioId: string) {
  const admin = await verificarAcceso('admin:eliminar-usuarios')

  // Solo llega aquí si el usuario es admin con el permiso correcto
  await db.usuario.delete({ where: { id: usuarioId } })

  revalidatePath('/admin/usuarios')
}

Patrones Avanzados

Subida de archivos con Server Actions

Los Server Actions manejan FormData de forma nativa, lo que los convierte en una opción natural para subir archivos. El objeto File dentro del FormData se puede procesar directamente en el servidor.

// app/actions/subir-archivo.ts
'use server'

import { writeFile, mkdir } from 'fs/promises'
import { join } from 'path'
import { auth } from '@/lib/auth'

const TIPOS_PERMITIDOS = ['image/jpeg', 'image/png', 'image/webp']
const TAMANO_MAXIMO = 5 * 1024 * 1024 // 5MB

export async function subirImagen(formData: FormData) {
  const sesion = await auth()
  if (!sesion?.user) {
    return { error: 'No autorizado' }
  }

  const archivo = formData.get('imagen') as File

  if (!archivo || archivo.size === 0) {
    return { error: 'No se seleccionó ningún archivo' }
  }

  if (!TIPOS_PERMITIDOS.includes(archivo.type)) {
    return { error: 'Tipo de archivo no permitido. Usa JPG, PNG o WebP.' }
  }

  if (archivo.size > TAMANO_MAXIMO) {
    return { error: 'El archivo excede el tamaño máximo de 5MB.' }
  }

  try {
    const bytes = await archivo.arrayBuffer()
    const buffer = Buffer.from(bytes)

    const nombreUnico = `${Date.now()}-${archivo.name.replace(/[^a-zA-Z0-9.-]/g, '_')}`
    const directorio = join(process.cwd(), 'public', 'uploads')
    await mkdir(directorio, { recursive: true })
    const rutaArchivo = join(directorio, nombreUnico)

    await writeFile(rutaArchivo, buffer)

    return {
      exito: true,
      url: `/uploads/${nombreUnico}`
    }
  } catch (error) {
    console.error('Error al subir archivo:', error)
    return { error: 'Error al procesar el archivo. Inténtalo de nuevo.' }
  }
}

Para producción, lo ideal es subir directamente a un servicio de almacenamiento como S3, Cloudflare R2 o Vercel Blob en vez de al filesystem local. Pero el patrón es el mismo: extraes el File del FormData, lo validas y lo envías al destino. No olvides configurar el bodySizeLimit en next.config.js si necesitas archivos más grandes que el límite por defecto de 1MB.

Operaciones con base de datos (Prisma)

Los Server Actions son compañeros perfectos para Prisma y otros ORMs porque el código se ejecuta en el servidor, donde tienes acceso directo a la base de datos. Un patrón habitual (y bastante cómodo) es organizar las acciones por entidad del dominio y agrupar operaciones CRUD relacionadas en un mismo archivo.

Las acciones de Prisma dentro de Server Actions se benefician de toda la potencia del ORM: transacciones, relaciones, agregaciones. Puedes crear un registro con relaciones anidadas, ejecutar todo dentro de una transacción, y si algo falla, se deshace automáticamente. No necesitas pensar en endpoints separados para cada operación, lo cual simplifica bastante la arquitectura.

Error boundaries y mutaciones paralelas

Cuando un Server Action lanza un error no capturado, puedes interceptarlo con los Error Boundaries de React. Esto es especialmente útil para errores inesperados que no quieres (o no puedes) manejar dentro de la propia acción.

En cuanto a mutaciones paralelas, puedes ejecutar varios Server Actions simultáneamente usando Promise.all o Promise.allSettled. Esto es útil cuando necesitas actualizar múltiples entidades independientes al mismo tiempo. Pero ojo: ten cuidado con las implicaciones de consistencia. Si una falla y otra tiene éxito, podrías dejar datos en un estado inconsistente. Usa transacciones de base de datos cuando la consistencia sea crítica.

Un patrón que funciona bien es agrupar mutaciones independientes en paralelo, pero mantener las que dependen unas de otras en secuencia:

// Mutaciones paralelas seguras
async function procesarPedido(pedidoId: string) {
  'use server'

  // Estas operaciones son independientes, se ejecutan en paralelo
  const [resultadoStock, resultadoNotificacion] = await Promise.allSettled([
    actualizarInventario(pedidoId),
    enviarEmailConfirmacion(pedidoId),
    registrarAnalytics('pedido_completado', pedidoId)
  ])

  // Verificar resultados individuales
  if (resultadoStock.status === 'rejected') {
    // Manejar fallo crítico del inventario
    await revertirPedido(pedidoId)
    throw new Error('No se pudo actualizar el inventario')
  }

  // La notificación por email no es crítica
  if (resultadoNotificacion.status === 'rejected') {
    console.error('Fallo al enviar email, se reintentará después')
    await encolarReintento('email', pedidoId)
  }

  revalidateTag('pedidos')
}

Conclusión: Buenas prácticas y cuándo usar Server Actions

Después de todo este recorrido, vamos a condensar las lecciones más importantes en una guía práctica de referencia rápida.

Resumen de buenas prácticas

  • Organiza las acciones en archivos dedicados con 'use server' a nivel de archivo. Evita el inline excepto para prototipos rápidos. Los archivos dedicados facilitan la reutilización y el testing.
  • Siempre valida las entradas con Zod u otra librería de validación. Nunca confíes en los datos del cliente, aunque vengan de tus propios formularios.
  • Verifica autenticación y autorización al inicio de cada acción. Usa wrappers o middlewares (como next-safe-action) para centralizar esta lógica.
  • Devuelve estados estructurados en lugar de lanzar errores. Objetos con campos como exito, errores y mensaje permiten que el componente cliente reaccione adecuadamente.
  • Usa useActionState para formularios y useOptimistic para actualizaciones inmediatas. Estos hooks de React 19 están diseñados para trabajar mano a mano con Server Actions.
  • Revalida el caché de forma estratégica: revalidatePath para rutas concretas, revalidateTag para invalidación semántica. No revalides más de lo necesario.
  • Implementa rate limiting para acciones sensibles o costosas. No subestimes lo que puede hacer un bot (o un bug en el cliente).
  • No expongas información interna en los mensajes de error. Loguea los detalles en el servidor y devuelve mensajes genéricos al cliente.
  • Configura allowedOrigins en producción para reforzar la protección CSRF.
  • Considera next-safe-action para proyectos medianos y grandes donde la consistencia y el tipado son cruciales.

Server Actions vs API Routes: cuándo usar cada uno

Los Server Actions no reemplazan completamente a las API Routes. Cada uno tiene su lugar, y entender la diferencia te va a ahorrar decisiones equivocadas.

Usa Server Actions cuando:

  • Necesitas procesar formularios o mutaciones de datos iniciadas por el usuario.
  • Quieres progressive enhancement (que los formularios funcionen sin JavaScript).
  • La operación está ligada directamente a la interacción con la interfaz.
  • Quieres una integración limpia con el sistema de revalidación de caché de Next.js.
  • Buscas un código más simple y con menos boilerplate que un endpoint REST.

Usa API Routes cuando:

  • Necesitas webhooks que reciban datos de servicios externos.
  • Tienes clientes que no son tu aplicación Next.js (apps móviles, otros servicios).
  • Necesitas endpoints GET que devuelvan datos en formatos específicos (JSON, XML, CSV).
  • Quieres exponer una API REST o GraphQL pública.
  • Necesitas control granular sobre cabeceras HTTP, códigos de respuesta o streaming.

En la práctica, la mayoría de los proyectos combinan ambos. Server Actions para la interactividad interna de la aplicación y API Routes para integraciones externas. No es una decisión binaria, sino cuestión de usar la herramienta correcta en cada situación.

Los Server Actions representan uno de los avances más significativos en la experiencia de desarrollo con React y Next.js de los últimos años. Eliminan una cantidad enorme de boilerplate, mejoran la seguridad por defecto y hacen que el flujo de datos entre cliente y servidor sea intuitivo y directo. Si aún no los has incorporado a tu flujo de trabajo, este es el momento perfecto para empezar.

Sobre el Autor Editorial Team

Our team of expert writers and editors.