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:

  1. Verificar que el producto existe
  2. Verificar que hay stock disponible
  3. Guardar en la base de datos
  4. 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

  1. Más seguro - Tu código del servidor nunca se expone al cliente
  2. Más simple - No necesitas crear API routes manualmente
  3. Progresivo - Funciona incluso sin JavaScript habilitado
  4. Automático - NextJS maneja la comunicación cliente-servidor
  5. 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:

  1. NextJS captura el submit
  2. Envía los datos al servidor
  3. Ejecuta crearProducto() en el servidor
  4. Guarda en la base de datos
  5. Actualiza la caché con revalidatePath()
  6. Redirige al usuario con redirect()
  7. 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 ejecuta
  • data: datos del formulario
  • method: método HTTP
  • action: 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:

  1. 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>
}
  1. 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:

  1. Funciones que se ejecutan en el servidor con 'use server'
  2. Forma moderna de trabajar con formularios y mutaciones
  3. Más seguras que API routes tradicionales
  4. Validación siempre en el servidor
  5. Usa revalidatePath() para actualizar caché
  6. Usa redirect() para redirigir después de la acción
  7. useFormState() para manejar estados y errores
  8. useFormStatus() para estados de carga
  9. Funciona con formularios HTML nativos (progresivo)
  10. Puedes llamarlas desde event handlers

Tabla de decisión:

NecesitasUsa
Enviar formularioServer Action con action={miAction}
Validar datosZod + try/catch en Server Action
Mostrar erroresuseFormState en Client Component
Estado de cargauseFormStatus en Client Component
Actualizar UIrevalidatePath() en Server Action
Redirigir usuarioredirect() en Server Action
Click en botónImportar y llamar Server Action
Acceso a DBDirecto en Server Action