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,Datey 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:
- estado: El valor actual del estado, que se actualiza con lo que retorna el Server Action.
- accionFormulario: La función envuelta que debes pasar al atributo
actiondel formulario. - isPending: Un booleano que es
truemientras 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-actiono soluciones comoupstash/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,erroresymensajepermiten que el componente cliente reaccione adecuadamente. - Usa
useActionStatepara formularios yuseOptimisticpara 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:
revalidatePathpara rutas concretas,revalidateTagpara 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
allowedOriginsen producción para reforzar la protección CSRF. - Considera
next-safe-actionpara 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.