Tema
Server Actions y formularios
Las Server Actions son funciones que se ejecutan en el servidor y que puedes llamar directamente desde un formulario o un botón, sin crear una API. Next.js se encarga de la petición por ti. Aprenderás a crearlas, validarlas con zod, mostrar errores y estados de carga con useActionState, y actualizar la interfaz al instante.
Concepto
Antes, para guardar un formulario necesitabas:
- Crear un endpoint
POST /api/contacto. - En el cliente, manejar el submit, hacer
fetch, manejar carga y errores. - Refrescar los datos.
Con Server Actions:
tsx
// app/contacto/page.tsx
export default function Contacto() {
async function enviar(formData: FormData) {
'use server' // ← esta función corre en el servidor
const email = formData.get('email')
console.log('Nuevo contacto:', email) // aparece en la terminal, no en el navegador
}
return (
<form action={enviar}>
<input name="email" type="email" required />
<button>Enviar</button>
</form>
)
}Analogía: es como tener un timbre directo a la cocina. En lugar de que el mesero (API) lleve tu orden, aprietas el botón y la cocina la recibe.
Ventajas:
- Menos código: sin endpoints, sin
fetchmanual. - Funcionan sin JavaScript (mejora progresiva): el formulario se envía aunque el JS no haya cargado.
- Se integran con la caché: puedes revalidar datos y redirigir en la misma función.
Archivo de acciones
Lo habitual es poner las acciones en un archivo con 'use server' en la primera línea: todas sus funciones exportadas serán Server Actions y podrás importarlas en Server o Client Components.
ts
// app/tareas/acciones.ts
'use server'
import { revalidatePath } from 'next/cache'
// Ejemplo en memoria. En la vida real: Prisma (lección 12)
const tareas: { id: number; texto: string; hecha: boolean }[] = []
export async function obtenerTareas() {
return tareas
}
export async function crearTarea(formData: FormData) {
const texto = String(formData.get('texto') ?? '').trim()
if (!texto) return
tareas.push({ id: Date.now(), texto, hecha: false })
revalidatePath('/tareas') // vuelve a renderizar la página con los datos nuevos
}
export async function alternarTarea(id: number) {
const tarea = tareas.find((t) => t.id === id)
if (tarea) tarea.hecha = !tarea.hecha
revalidatePath('/tareas')
}
export async function eliminarTarea(id: number) {
const i = tareas.findIndex((t) => t.id === id)
if (i !== -1) tareas.splice(i, 1)
revalidatePath('/tareas')
}tsx
// app/tareas/page.tsx (Server Component)
import { obtenerTareas, crearTarea, alternarTarea, eliminarTarea } from './acciones'
export default async function Tareas() {
const tareas = await obtenerTareas()
return (
<main className="mx-auto max-w-md p-8">
<h1 className="mb-4 text-2xl font-bold">Tareas</h1>
<form action={crearTarea} className="mb-4 flex gap-2">
<input name="texto" placeholder="Nueva tarea" className="flex-1 rounded border px-3 py-2" required />
<button className="rounded bg-violet-600 px-4 text-white">Agregar</button>
</form>
<ul className="space-y-2">
{tareas.map((t) => (
<li key={t.id} className="flex items-center gap-2">
{/* bind "pre-llena" el argumento id */}
<form action={alternarTarea.bind(null, t.id)}>
<button aria-label="Alternar">{t.hecha ? '☑' : '☐'}</button>
</form>
<span className={t.hecha ? 'flex-1 line-through text-gray-400' : 'flex-1'}>{t.texto}</span>
<form action={eliminarTarea.bind(null, t.id)}>
<button className="text-red-600" aria-label="Eliminar">✕</button>
</form>
</li>
))}
</ul>
</main>
)
}Todo funciona sin una sola línea de useState ni fetch, e incluso con JavaScript desactivado.
Datos en memoria
El arreglo en memoria sirve para la demostración, pero se reinicia al reiniciar el servidor y no se comparte entre instancias. En la lección de Prisma lo cambiarás por una base de datos.
Validación con zod y errores en el formulario
Las Server Actions son endpoints públicos: cualquiera puede llamarlas con datos arbitrarios. Siempre valida en el servidor.
bash
npm i zodts
'use server'
import { z } from 'zod'
const ContactoSchema = z.object({
nombre: z.string().trim().min(2, 'Escribe tu nombre'),
email: z.email('Email inválido'),
mensaje: z.string().trim().min(10, 'El mensaje debe tener al menos 10 caracteres').max(1000)
})
// Forma del estado que devolvemos al formulario
export type EstadoContacto = {
ok?: boolean
mensaje?: string
errores?: Partial<Record<'nombre' | 'email' | 'mensaje', string[]>>
valores?: { nombre?: string; email?: string; mensaje?: string }
}
// Con useActionState, la acción recibe (estadoAnterior, formData)
export async function enviarContacto(_prev: EstadoContacto, formData: FormData): Promise<EstadoContacto> {
const valores = {
nombre: String(formData.get('nombre') ?? ''),
email: String(formData.get('email') ?? ''),
mensaje: String(formData.get('mensaje') ?? '')
}
const resultado = ContactoSchema.safeParse(valores)
if (!resultado.success) {
return {
ok: false,
mensaje: 'Revisa los campos marcados',
errores: z.flattenError(resultado.error).fieldErrors,
valores // para no perder lo que escribió el usuario
}
}
try {
await new Promise((r) => setTimeout(r, 1000)) // aquí: guardar en BD o enviar email (Resend, Nodemailer)
return { ok: true, mensaje: '¡Gracias! Te responderemos en menos de 24 horas.' }
} catch {
return { ok: false, mensaje: 'No pudimos enviar tu mensaje. Intenta de nuevo.', valores }
}
}tsx
'use client'
import { useActionState } from 'react'
import { useFormStatus } from 'react-dom'
import { enviarContacto, type EstadoContacto } from './acciones'
function BotonEnviar() {
const { pending } = useFormStatus() // estado del formulario padre
return (
<button disabled={pending} className="rounded bg-violet-600 px-4 py-2 text-white disabled:opacity-60">
{pending ? 'Enviando…' : 'Enviar mensaje'}
</button>
)
}
function ErrorCampo({ errores }: { errores?: string[] }) {
if (!errores?.length) return null
return <p className="text-sm text-red-600">{errores[0]}</p>
}
const inicial: EstadoContacto = {}
export default function FormContacto() {
const [estado, accion] = useActionState(enviarContacto, inicial)
if (estado.ok) {
return <p role="status" className="rounded bg-green-50 p-4 text-green-800">{estado.mensaje}</p>
}
return (
<form action={accion} className="space-y-4" noValidate>
{estado.mensaje && <p role="alert" className="rounded bg-red-50 p-3 text-red-700">{estado.mensaje}</p>}
<div>
<label htmlFor="nombre" className="block font-medium">Nombre</label>
<input id="nombre" name="nombre" defaultValue={estado.valores?.nombre} className="w-full rounded border px-3 py-2" />
<ErrorCampo errores={estado.errores?.nombre} />
</div>
<div>
<label htmlFor="email" className="block font-medium">Email</label>
<input id="email" name="email" type="email" defaultValue={estado.valores?.email} className="w-full rounded border px-3 py-2" />
<ErrorCampo errores={estado.errores?.email} />
</div>
<div>
<label htmlFor="mensaje" className="block font-medium">Mensaje</label>
<textarea id="mensaje" name="mensaje" rows={4} defaultValue={estado.valores?.mensaje} className="w-full rounded border px-3 py-2" />
<ErrorCampo errores={estado.errores?.mensaje} />
</div>
<BotonEnviar />
</form>
)
}tsx
import FormContacto from './FormContacto'
export const metadata = { title: 'Contacto' }
export default function Contacto() {
return (
<main className="mx-auto max-w-lg p-8">
<h1 className="mb-6 text-3xl font-bold">Contáctanos</h1>
<FormContacto />
</main>
)
}z.flattenError
En zod 4, z.flattenError(error).fieldErrors devuelve un objeto como { email: ['Email inválido'] }. En zod 3 se usaba error.flatten().fieldErrors.
Redirigir después de guardar
ts
'use server'
import { redirect } from 'next/navigation'
import { revalidatePath } from 'next/cache'
export async function crearProducto(formData: FormData) {
// ...validar y guardar
const id = 42
revalidatePath('/admin/productos')
redirect(`/admin/productos/${id}`) // lanza una excepción especial: va FUERA de try/catch
}Error común
Llamar a redirect() dentro de un try/catch. redirect funciona lanzando un error especial que Next.js atrapa; tu catch lo interceptaría. Llama a redirect después del bloque try/catch.
Llamar acciones sin formulario
Desde un Client Component puedes llamar una Server Action como una función normal, por ejemplo en un onClick. Usa useTransition para saber si está en curso:
tsx
'use client'
import { useTransition } from 'react'
import { eliminarTarea } from './acciones'
export function BotonEliminar({ id }: { id: number }) {
const [pendiente, startTransition] = useTransition()
return (
<button
disabled={pendiente}
onClick={() => {
if (!confirm('¿Eliminar esta tarea?')) return
startTransition(async () => {
await eliminarTarea(id)
})
}}
>
{pendiente ? 'Eliminando…' : 'Eliminar'}
</button>
)
}Actualizaciones optimistas: useOptimistic
Para que la interfaz se actualice antes de que responda el servidor (por ejemplo, un "me gusta" o agregar un comentario):
tsx
'use client'
import { useOptimistic, useRef } from 'react'
import { agregarComentario } from './acciones'
type Comentario = { id: number | string; texto: string; enviando?: boolean }
export function Comentarios({ comentarios }: { comentarios: Comentario[] }) {
const formRef = useRef<HTMLFormElement>(null)
const [optimistas, agregarOptimista] = useOptimistic(
comentarios,
(actuales: Comentario[], nuevo: string) => [...actuales, { id: `temp-${Date.now()}`, texto: nuevo, enviando: true }]
)
async function accion(formData: FormData) {
const texto = String(formData.get('texto'))
agregarOptimista(texto) // aparece al instante
formRef.current?.reset()
await agregarComentario(texto) // el servidor guarda y revalida; la lista real reemplaza a la optimista
}
return (
<>
<ul>
{optimistas.map((c) => (
<li key={c.id} className={c.enviando ? 'opacity-50' : ''}>{c.texto}</li>
))}
</ul>
<form ref={formRef} action={accion}>
<input name="texto" required />
<button>Comentar</button>
</form>
</>
)
}Seguridad en Server Actions
Cada Server Action es un endpoint POST accesible desde fuera. Trátalas como una API:
ts
'use server'
import { auth } from '@/auth' // Auth.js (lección 11)
import { z } from 'zod'
export async function eliminarPost(id: number) {
// 1. Autenticación: ¿hay sesión?
const sesion = await auth()
if (!sesion?.user) throw new Error('No autenticado')
// 2. Validación de entrada
const postId = z.number().int().positive().parse(id)
// 3. Autorización: ¿este usuario puede borrar ESTE post?
// const post = await prisma.post.findUnique({ where: { id: postId } })
// if (post?.autorId !== sesion.user.id) throw new Error('Sin permiso')
// 4. Ejecutar
// await prisma.post.delete({ where: { id: postId } })
}Nunca confíes en el cliente
Ocultar un botón "Eliminar" a los usuarios normales no impide que alguien llame a la acción. Verifica sesión, permisos y datos dentro de cada Server Action.
Buena práctica
- Agrupa las acciones por feature en archivos
acciones.tscon'use server'. - Devuelve un objeto de estado consistente (
ok,mensaje,errores) para que los formularios lo muestren. - Usa
revalidatePath/revalidateTagdespués de cada mutación para que la interfaz refleje los cambios.
Ejercicios
- Crea un formulario de suscripción a un newsletter con una Server Action que valide el email con zod, devuelva errores y muestre un mensaje de éxito. El botón debe mostrar "Suscribiendo…" mientras se procesa.
- Agrega a la página de tareas un botón "Borrar completadas" que llame a una Server Action con
useTransition.
Ver solución
ts
// 1. app/newsletter/acciones.ts
'use server'
import { z } from 'zod'
export type Estado = { ok?: boolean; error?: string; email?: string }
export async function suscribir(_: Estado, formData: FormData): Promise<Estado> {
const email = String(formData.get('email') ?? '')
const r = z.email().safeParse(email)
if (!r.success) return { error: 'Escribe un email válido', email }
await new Promise((res) => setTimeout(res, 800)) // guardar en BD / Mailchimp
return { ok: true }
}tsx
// app/newsletter/FormNewsletter.tsx
'use client'
import { useActionState } from 'react'
import { suscribir, type Estado } from './acciones'
export default function FormNewsletter() {
const [estado, accion, pendiente] = useActionState<Estado, FormData>(suscribir, {})
if (estado.ok) return <p>✅ ¡Listo! Revisa tu correo.</p>
return (
<form action={accion} className="flex flex-wrap gap-2">
<input name="email" type="email" defaultValue={estado.email} className="rounded border px-3 py-2" />
<button disabled={pendiente}>{pendiente ? 'Suscribiendo…' : 'Suscribirme'}</button>
{estado.error && <p className="w-full text-red-600">{estado.error}</p>}
</form>
)
}ts
// 2. En app/tareas/acciones.ts
export async function borrarCompletadas() {
for (let i = tareas.length - 1; i >= 0; i--) {
if (tareas[i].hecha) tareas.splice(i, 1)
}
revalidatePath('/tareas')
}tsx
// app/tareas/BotonBorrarCompletadas.tsx
'use client'
import { useTransition } from 'react'
import { borrarCompletadas } from './acciones'
export default function BotonBorrarCompletadas() {
const [pendiente, start] = useTransition()
return (
<button disabled={pendiente} onClick={() => start(() => borrarCompletadas())}>
{pendiente ? 'Borrando…' : 'Borrar completadas'}
</button>
)
}Resumen
- Una Server Action es una función
asynccon'use server'que corre en el servidor y se usa enform action. - Valida siempre con zod; devuelve un estado con errores y úsalo con
useActionStateyuseFormStatus. - Tras mutar datos, usa
revalidatePath/revalidateTag;redirectva fuera detry/catch. useTransitionpara llamarlas sin formulario yuseOptimisticpara respuestas instantáneas.- Son endpoints públicos: verifica autenticación y permisos en cada una.