Skip to content

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:

  1. Crear un endpoint POST /api/contacto.
  2. En el cliente, manejar el submit, hacer fetch, manejar carga y errores.
  3. 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 fetch manual.
  • 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 zod
ts
'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.ts con 'use server'.
  • Devuelve un objeto de estado consistente (ok, mensaje, errores) para que los formularios lo muestren.
  • Usa revalidatePath/revalidateTag después de cada mutación para que la interfaz refleje los cambios.

Ejercicios ​

  1. 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.
  2. 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 async con 'use server' que corre en el servidor y se usa en form action.
  • Valida siempre con zod; devuelve un estado con errores y úsalo con useActionState y useFormStatus.
  • Tras mutar datos, usa revalidatePath/revalidateTag; redirect va fuera de try/catch.
  • useTransition para llamarlas sin formulario y useOptimistic para respuestas instantáneas.
  • Son endpoints públicos: verifica autenticación y permisos en cada una.

Academia Web — aprende, copia y construye.