Server Actions
Server Actions (acciones del servidor) son funciones que se ejecutan en el servidor y te permiten modificar datos de forma segura. Son la forma moderna y recomendada de trabajar con formularios y mutaciones en NextJS 15.
¿Qué son Server Actions?
Imagina que tienes una tienda online y un usuario quiere agregar un producto al carrito. Esa acción necesita:
- Verificar que el producto existe
- Verificar que hay stock disponible
- Guardar en la base de datos
- Actualizar la UI
Antes de Server Actions:
Necesitabas crear una API route (ruta de API), hacer fetch desde el cliente, manejar errores, actualizar estados... mucho código repetitivo.
Con Server Actions:
Creas una función que se ejecuta directamente en el servidor. NextJS maneja todo lo demás automáticamente.
// Función que se ejecuta en el servidor
async function agregarAlCarrito(productoId) {
'use server'
// Acceso directo a la base de datos
await db.carrito.create({
data: { productoId, usuarioId: getUser().id }
})
// NextJS actualiza la UI automáticamente
}
¿Por qué 'Server Actions'?
Se llaman "Server Actions" (acciones del servidor) porque son funciones que se ejecutan en el servidor para realizar acciones como crear, actualizar o eliminar datos. A diferencia de consultas (queries) que solo leen datos, las acciones modifican el estado de tu aplicación.
¿Por qué usar Server Actions?
El problema que resuelven
Antes, para enviar datos al servidor necesitabas:
// ❌ Forma antigua: mucho código repetitivo
'use client'
export default function FormularioProducto() {
const [nombre, setNombre] = useState('')
const [precio, setPrecio] = useState('')
const [enviando, setEnviando] = useState(false)
const [error, setError] = useState(null)
const handleSubmit = async (e) => {
e.preventDefault()
setEnviando(true)
setError(null)
try {
const response = await fetch('/api/productos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nombre, precio })
})
if (!response.ok) {
throw new Error('Error al crear producto')
}
// Actualizar UI manualmente
router.refresh()
} catch (err) {
setError(err.message)
} finally {
setEnviando(false)
}
}
return (
<form onSubmit={handleSubmit}>
{/* formulario */}
</form>
)
}
Con Server Actions:
// ✓ Forma nueva: simple y directo
async function crearProducto(formData) {
'use server'
const nombre = formData.get('nombre')
const precio = formData.get('precio')
await db.producto.create({
data: { nombre, precio }
})
revalidatePath('/productos')
}
export default function FormularioProducto() {
return (
<form action={crearProducto}>
<input name="nombre" />
<input name="precio" />
<button type="submit">Crear</button>
</form>
)
}
Menos código, más simple, más seguro.
Beneficios principales
- Más seguro - Tu código del servidor nunca se expone al cliente
- Más simple - No necesitas crear API routes manualmente
- Progresivo - Funciona incluso sin JavaScript habilitado
- Automático - NextJS maneja la comunicación cliente-servidor
- Mejor UX - Estados de carga y errores integrados
Sintaxis básica
La directiva 'use server'
Para crear una Server Action, usa la directiva 'use server'
:
Opción 1: Dentro de la función (inline)
export default function Page() {
// Esta función se ejecuta en el servidor
async function crearUsuario(formData) {
'use server'
const nombre = formData.get('nombre')
await db.usuario.create({ data: { nombre } })
}
return (
<form action={crearUsuario}>
<input name="nombre" />
<button type="submit">Crear</button>
</form>
)
}
Opción 2: En un archivo separado
// actions/usuarios.ts
'use server'
// Todas las funciones en este archivo son Server Actions
export async function crearUsuario(formData) {
const nombre = formData.get('nombre')
await db.usuario.create({ data: { nombre } })
}
export async function actualizarUsuario(id, formData) {
const nombre = formData.get('nombre')
await db.usuario.update({
where: { id },
data: { nombre }
})
}
// app/usuarios/nuevo/page.tsx
import { crearUsuario } from '@/actions/usuarios'
export default function NuevoUsuarioPage() {
return (
<form action={crearUsuario}>
<input name="nombre" />
<button type="submit">Crear</button>
</form>
)
}
Recomendación
Usa archivos separados (opción 2) para organizar mejor tu código. Crea una carpeta actions/
en la raíz de tu proyecto con todas tus Server Actions.
Formularios con Server Actions
La forma más común de usar Server Actions es con formularios HTML nativos.
Formulario básico
// actions/productos.ts
'use server'
export async function crearProducto(formData: FormData) {
const nombre = formData.get('nombre') as string
const precio = Number(formData.get('precio'))
await db.producto.create({
data: { nombre, precio }
})
revalidatePath('/productos')
redirect('/productos')
}
// app/productos/nuevo/page.tsx
import { crearProducto } from '@/actions/productos'
export default function NuevoProductoPage() {
return (
<form action={crearProducto} className="space-y-4">
<div>
<label htmlFor="nombre">Nombre del producto</label>
<input
id="nombre"
name="nombre"
type="text"
required
className="border rounded px-3 py-2"
/>
</div>
<div>
<label htmlFor="precio">Precio</label>
<input
id="precio"
name="precio"
type="number"
required
step="0.01"
className="border rounded px-3 py-2"
/>
</div>
<button type="submit" className="bg-blue-600 text-white px-4 py-2 rounded">
Crear Producto
</button>
</form>
)
}
Qué sucede cuando el usuario envía el formulario:
- NextJS captura el submit
- Envía los datos al servidor
- Ejecuta
crearProducto()
en el servidor - Guarda en la base de datos
- Actualiza la caché con
revalidatePath()
- Redirige al usuario con
redirect()
- El usuario ve la lista actualizada
Todo automático.
Obtener datos del FormData
FormData es un objeto especial que contiene los datos del formulario:
'use server'
export async function crearProducto(formData: FormData) {
// Obtener valores individuales
const nombre = formData.get('nombre') // string | null
const precio = formData.get('precio') // string | null
const activo = formData.get('activo') // "on" si checkbox marcado
// Convertir tipos
const precioNumero = Number(precio)
const activoBoolean = activo === 'on'
// Obtener múltiples valores (checkbox group, select multiple)
const categorias = formData.getAll('categorias') // string[]
// Ver todos los datos
console.log(Object.fromEntries(formData))
await db.producto.create({
data: {
nombre: nombre as string,
precio: precioNumero,
activo: activoBoolean,
categorias: categorias as string[]
}
})
}
Validación de datos
Siempre valida los datos en el servidor, nunca confíes en la validación del cliente.
Validación manual
'use server'
export async function crearProducto(formData: FormData) {
const nombre = formData.get('nombre') as string
const precio = Number(formData.get('precio'))
// Validar nombre
if (!nombre || nombre.trim().length < 3) {
throw new Error('El nombre debe tener al menos 3 caracteres')
}
// Validar precio
if (isNaN(precio) || precio <= 0) {
throw new Error('El precio debe ser mayor a 0')
}
// Validar que no exista
const existente = await db.producto.findUnique({
where: { nombre }
})
if (existente) {
throw new Error('Ya existe un producto con ese nombre')
}
// Todo válido, crear producto
await db.producto.create({
data: { nombre, precio }
})
revalidatePath('/productos')
}
Validación con Zod
Zod es una librería popular para validación de datos:
npm install zod
// actions/productos.ts
'use server'
import { z } from 'zod'
// Definir esquema de validación
const productoSchema = z.object({
nombre: z.string().min(3, 'Mínimo 3 caracteres'),
precio: z.number().positive('Debe ser mayor a 0'),
descripcion: z.string().max(500, 'Máximo 500 caracteres'),
stock: z.number().int().min(0, 'No puede ser negativo')
})
export async function crearProducto(formData: FormData) {
// Parsear y validar datos
const datos = productoSchema.parse({
nombre: formData.get('nombre'),
precio: Number(formData.get('precio')),
descripcion: formData.get('descripcion'),
stock: Number(formData.get('stock'))
})
// Si llegamos aquí, los datos son válidos
await db.producto.create({
data: datos
})
revalidatePath('/productos')
redirect('/productos')
}
Si la validación falla, Zod lanza un error automáticamente.
Retornar errores al usuario
En lugar de lanzar errores, puedes retornarlos para mostrarlos en la UI:
// actions/productos.ts
'use server'
import { z } from 'zod'
const productoSchema = z.object({
nombre: z.string().min(3),
precio: z.number().positive()
})
export async function crearProducto(prevState: any, formData: FormData) {
try {
// Validar
const datos = productoSchema.parse({
nombre: formData.get('nombre'),
precio: Number(formData.get('precio'))
})
// Crear
await db.producto.create({ data: datos })
revalidatePath('/productos')
return { success: true, message: 'Producto creado' }
} catch (error) {
if (error instanceof z.ZodError) {
return {
success: false,
errors: error.errors.map(e => e.message)
}
}
return {
success: false,
message: 'Error al crear producto'
}
}
}
// app/productos/nuevo/page.tsx
'use client'
import { useFormState } from 'react-dom'
import { crearProducto } from '@/actions/productos'
export default function NuevoProductoPage() {
const [state, formAction] = useFormState(crearProducto, null)
return (
<form action={formAction}>
<input name="nombre" />
<input name="precio" type="number" />
{state?.errors && (
<div className="text-red-600">
{state.errors.map((error, i) => (
<p key={i}>{error}</p>
))}
</div>
)}
{state?.success && (
<div className="text-green-600">
{state.message}
</div>
)}
<button type="submit">Crear</button>
</form>
)
}
useFormState
useFormState
es un hook de React que te permite acceder al estado retornado por tu Server Action. Úsalo en Client Components cuando necesites mostrar errores o mensajes al usuario.
Estados de carga
Mostrar al usuario que algo está sucediendo mientras se procesa la acción.
Con useFormStatus
'use client'
import { useFormStatus } from 'react-dom'
function BotonEnviar() {
const { pending } = useFormStatus()
return (
<button
type="submit"
disabled={pending}
className="bg-blue-600 text-white px-4 py-2 rounded disabled:bg-gray-400"
>
{pending ? 'Creando...' : 'Crear Producto'}
</button>
)
}
export default function FormularioProducto({ action }) {
return (
<form action={action}>
<input name="nombre" />
<input name="precio" />
<BotonEnviar />
</form>
)
}
useFormStatus()
te da información sobre el estado del formulario:
pending
: true mientras la acción se ejecutadata
: datos del formulariomethod
: método HTTPaction
: URL de la acción
Deshabilitar campos durante envío
'use client'
import { useFormStatus } from 'react-dom'
export default function FormularioProducto({ action }) {
const { pending } = useFormStatus()
return (
<form action={action}>
<input
name="nombre"
disabled={pending}
className="border rounded px-3 py-2"
/>
<input
name="precio"
type="number"
disabled={pending}
className="border rounded px-3 py-2"
/>
<button
type="submit"
disabled={pending}
className="bg-blue-600 text-white px-4 py-2 rounded"
>
{pending ? 'Guardando...' : 'Guardar'}
</button>
</form>
)
}
Revalidación y redirección
Después de modificar datos, necesitas actualizar la UI.
revalidatePath
Invalida la caché de una ruta específica:
'use server'
import { revalidatePath } from 'next/cache'
export async function crearProducto(formData: FormData) {
await db.producto.create({ /* ... */ })
// Actualizar la página de productos
revalidatePath('/productos')
// También puedes actualizar múltiples rutas
revalidatePath('/productos/destacados')
revalidatePath('/')
}
revalidateTag
Invalida múltiples rutas con el mismo tag:
// Al hacer fetch, añadir tags
const productos = await fetch('https://api.ejemplo.com/productos', {
next: { tags: ['productos'] }
})
// Luego, en tu Server Action
'use server'
import { revalidateTag } from 'next/cache'
export async function crearProducto(formData: FormData) {
await db.producto.create({ /* ... */ })
// Actualizar todas las rutas con tag 'productos'
revalidateTag('productos')
}
redirect
Redirigir al usuario después de completar una acción:
'use server'
import { redirect } from 'next/navigation'
import { revalidatePath } from 'next/cache'
export async function crearProducto(formData: FormData) {
const producto = await db.producto.create({ /* ... */ })
// Actualizar caché
revalidatePath('/productos')
// Redirigir al nuevo producto
redirect(`/productos/${producto.id}`)
}
redirect lanza un error
redirect()
internamente lanza un error para detener la ejecución. Esto es normal. No lo captures con try/catch a menos que sea intencional.
Llamar Server Actions desde Client Components
Puedes llamar Server Actions desde event handlers:
// actions/productos.ts
'use server'
export async function eliminarProducto(id: string) {
await db.producto.delete({ where: { id } })
revalidatePath('/productos')
}
// components/BotonEliminar.tsx
'use client'
import { eliminarProducto } from '@/actions/productos'
import { useState } from 'react'
export default function BotonEliminar({ id }: { id: string }) {
const [eliminando, setEliminando] = useState(false)
const handleClick = async () => {
if (!confirm('¿Estás seguro?')) return
setEliminando(true)
try {
await eliminarProducto(id)
} catch (error) {
alert('Error al eliminar')
} finally {
setEliminando(false)
}
}
return (
<button
onClick={handleClick}
disabled={eliminando}
className="text-red-600 hover:text-red-700"
>
{eliminando ? 'Eliminando...' : 'Eliminar'}
</button>
)
}
Ejemplos prácticos completos
Ejemplo 1: CRUD de productos
// actions/productos.ts
'use server'
import { z } from 'zod'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
import { db } from '@/lib/database'
const productoSchema = z.object({
nombre: z.string().min(3, 'Mínimo 3 caracteres'),
precio: z.number().positive('Debe ser mayor a 0'),
descripcion: z.string().max(500),
stock: z.number().int().min(0)
})
// CREATE
export async function crearProducto(formData: FormData) {
const datos = productoSchema.parse({
nombre: formData.get('nombre'),
precio: Number(formData.get('precio')),
descripcion: formData.get('descripcion'),
stock: Number(formData.get('stock'))
})
const producto = await db.producto.create({
data: datos
})
revalidatePath('/productos')
redirect(`/productos/${producto.id}`)
}
// UPDATE
export async function actualizarProducto(id: string, formData: FormData) {
const datos = productoSchema.parse({
nombre: formData.get('nombre'),
precio: Number(formData.get('precio')),
descripcion: formData.get('descripcion'),
stock: Number(formData.get('stock'))
})
await db.producto.update({
where: { id },
data: datos
})
revalidatePath('/productos')
revalidatePath(`/productos/${id}`)
redirect(`/productos/${id}`)
}
// DELETE
export async function eliminarProducto(id: string) {
await db.producto.delete({
where: { id }
})
revalidatePath('/productos')
}
// TOGGLE ACTIVO
export async function toggleActivo(id: string, activo: boolean) {
await db.producto.update({
where: { id },
data: { activo: !activo }
})
revalidatePath('/productos')
}
Ejemplo 2: Sistema de comentarios
// actions/comentarios.ts
'use server'
import { z } from 'zod'
import { revalidatePath } from 'next/cache'
import { getUsuarioActual } from '@/lib/auth'
const comentarioSchema = z.object({
texto: z.string().min(1).max(1000),
productoId: z.string()
})
export async function crearComentario(formData: FormData) {
// Obtener usuario autenticado
const usuario = await getUsuarioActual()
if (!usuario) {
throw new Error('Debes iniciar sesión')
}
// Validar datos
const { texto, productoId } = comentarioSchema.parse({
texto: formData.get('texto'),
productoId: formData.get('productoId')
})
// Crear comentario
await db.comentario.create({
data: {
texto,
productoId,
usuarioId: usuario.id
}
})
// Actualizar la página del producto
revalidatePath(`/productos/${productoId}`)
}
export async function eliminarComentario(id: string) {
const usuario = await getUsuarioActual()
if (!usuario) {
throw new Error('No autorizado')
}
// Verificar que el comentario pertenece al usuario
const comentario = await db.comentario.findUnique({
where: { id }
})
if (comentario.usuarioId !== usuario.id) {
throw new Error('No puedes eliminar este comentario')
}
await db.comentario.delete({ where: { id } })
revalidatePath(`/productos/${comentario.productoId}`)
}
export async function editarComentario(id: string, formData: FormData) {
const usuario = await getUsuarioActual()
const texto = formData.get('texto') as string
if (!usuario) {
throw new Error('No autorizado')
}
// Validar
if (!texto || texto.length > 1000) {
throw new Error('Texto inválido')
}
// Verificar permisos
const comentario = await db.comentario.findUnique({
where: { id }
})
if (comentario.usuarioId !== usuario.id) {
throw new Error('No puedes editar este comentario')
}
// Actualizar
await db.comentario.update({
where: { id },
data: { texto }
})
revalidatePath(`/productos/${comentario.productoId}`)
}
Ejemplo 3: Carrito de compras
// actions/carrito.ts
'use server'
import { cookies } from 'next/headers'
import { revalidatePath } from 'next/cache'
async function getCarritoId() {
const cookieStore = cookies()
let carritoId = cookieStore.get('carritoId')?.value
if (!carritoId) {
// Crear nuevo carrito
const carrito = await db.carrito.create({ data: {} })
carritoId = carrito.id
cookieStore.set('carritoId', carritoId)
}
return carritoId
}
export async function agregarAlCarrito(productoId: string, cantidad: number = 1) {
const carritoId = await getCarritoId()
// Verificar si ya existe en el carrito
const itemExistente = await db.carritoItem.findFirst({
where: { carritoId, productoId }
})
if (itemExistente) {
// Actualizar cantidad
await db.carritoItem.update({
where: { id: itemExistente.id },
data: { cantidad: itemExistente.cantidad + cantidad }
})
} else {
// Crear nuevo item
await db.carritoItem.create({
data: { carritoId, productoId, cantidad }
})
}
revalidatePath('/carrito')
}
export async function actualizarCantidad(itemId: string, cantidad: number) {
if (cantidad <= 0) {
await db.carritoItem.delete({ where: { id: itemId } })
} else {
await db.carritoItem.update({
where: { id: itemId },
data: { cantidad }
})
}
revalidatePath('/carrito')
}
export async function eliminarDelCarrito(itemId: string) {
await db.carritoItem.delete({ where: { id: itemId } })
revalidatePath('/carrito')
}
export async function vaciarCarrito() {
const carritoId = await getCarritoId()
await db.carritoItem.deleteMany({
where: { carritoId }
})
revalidatePath('/carrito')
}
Mejores prácticas
1. Siempre valida en el servidor
// ❌ Malo: solo validación en cliente
'use client'
function Formulario() {
const handleSubmit = (e) => {
const nombre = e.target.nombre.value
if (nombre.length < 3) {
alert('Mínimo 3 caracteres')
return
}
// Enviar al servidor sin validar
}
}
// ✓ Bueno: validación en servidor (y opcionalmente en cliente)
'use server'
export async function crearProducto(formData) {
const nombre = formData.get('nombre')
// Siempre validar en el servidor
if (!nombre || nombre.length < 3) {
throw new Error('Nombre inválido')
}
await db.producto.create({ data: { nombre } })
}
2. Usa Zod para validación compleja
// ✓ Bueno: esquema de validación reutilizable
const productoSchema = z.object({
nombre: z.string().min(3).max(100),
precio: z.number().positive(),
categorias: z.array(z.string()).min(1)
})
export async function crearProducto(formData) {
const datos = productoSchema.parse({
nombre: formData.get('nombre'),
precio: Number(formData.get('precio')),
categorias: formData.getAll('categorias')
})
await db.producto.create({ data: datos })
}
3. Maneja errores apropiadamente
// ✓ Bueno: retornar errores para mostrar en UI
export async function crearProducto(prevState, formData) {
try {
// Validar y crear
const datos = productoSchema.parse(/* ... */)
await db.producto.create({ data: datos })
revalidatePath('/productos')
return { success: true }
} catch (error) {
if (error instanceof z.ZodError) {
return {
success: false,
errors: error.errors.map(e => `${e.path}: ${e.message}`)
}
}
return {
success: false,
message: 'Error inesperado'
}
}
}
4. Revalida las rutas necesarias
// ✓ Bueno: revalidar todas las rutas afectadas
export async function actualizarProducto(id, formData) {
await db.producto.update({ where: { id }, data: { /* ... */ } })
revalidatePath('/productos') // Lista de productos
revalidatePath(`/productos/${id}`) // Detalle del producto
revalidatePath('/') // Home si muestra productos
}
5. Protege acciones sensibles
// ✓ Bueno: verificar autenticación y permisos
export async function eliminarProducto(id) {
const usuario = await getUsuarioActual()
if (!usuario) {
throw new Error('Debes iniciar sesión')
}
if (!usuario.esAdmin) {
throw new Error('No tienes permisos')
}
await db.producto.delete({ where: { id } })
revalidatePath('/productos')
}
6. Usa tipos de TypeScript
// ✓ Bueno: tipar parámetros y retornos
export async function crearProducto(
formData: FormData
): Promise<{ success: boolean; error?: string }> {
try {
const nombre = formData.get('nombre') as string
const precio = Number(formData.get('precio'))
await db.producto.create({
data: { nombre, precio }
})
return { success: true }
} catch (error) {
return {
success: false,
error: 'Error al crear producto'
}
}
}
Limitaciones
No puedes usar Server Actions en:
- Event handlers de Client Components (directamente)
// ❌ No funciona
'use client'
export default function Boton() {
async function handleClick() {
'use server' // Error: no puedes usar 'use server' aquí
await db.algo()
}
return <button onClick={handleClick}>Click</button>
}
// ✓ Correcto: importa la Server Action
import { miServerAction } from '@/actions/algo'
export default function Boton() {
return <button onClick={miServerAction}>Click</button>
}
- Retornar datos complejos
Server Actions solo pueden retornar datos serializables (JSON):
// ❌ No puedes retornar
- Funciones
- Clases
- Dates (usa strings ISO)
- undefined (usa null)
// ✓ Puedes retornar
- Strings, numbers, booleans
- Objects planos
- Arrays
- null
Resumen
Puntos clave sobre Server Actions:
- Funciones que se ejecutan en el servidor con
'use server'
- Forma moderna de trabajar con formularios y mutaciones
- Más seguras que API routes tradicionales
- Validación siempre en el servidor
- Usa
revalidatePath()
para actualizar caché - Usa
redirect()
para redirigir después de la acción useFormState()
para manejar estados y erroresuseFormStatus()
para estados de carga- Funciona con formularios HTML nativos (progresivo)
- Puedes llamarlas desde event handlers
Tabla de decisión:
Necesitas | Usa |
---|---|
Enviar formulario | Server Action con action={miAction} |
Validar datos | Zod + try/catch en Server Action |
Mostrar errores | useFormState en Client Component |
Estado de carga | useFormStatus en Client Component |
Actualizar UI | revalidatePath() en Server Action |
Redirigir usuario | redirect() en Server Action |
Click en botón | Importar y llamar Server Action |
Acceso a DB | Directo en Server Action |