Server Actions

Server Actions son funciones que se ejecutan en el servidor pero se pueden llamar desde el cliente. Sirven para mutations (crear, actualizar, eliminar datos) sin necesitar API routes.

Crear un Server Action

Agrega "use server" dentro de la funcion o al inicio del archivo:

tsx
// app/actions.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 },
  })
}

Usar en un formulario

La forma mas directa es pasar el action al form:

tsx
// app/productos/nuevo/page.tsx
import { crearProducto } from "@/app/actions"

export default function NuevoProductoPage() {
  return (
    <form action={crearProducto}>
      <input name="nombre" placeholder="Nombre del producto" required />
      <input name="precio" type="number" placeholder="Precio" required />
      <button type="submit">Crear Producto</button>
    </form>
  )
}

Esto funciona sin JavaScript del lado del cliente. El formulario se envia al servidor, se ejecuta el action, y la pagina se actualiza.

Validacion con Zod

Siempre valida los datos en el servidor:

tsx
// app/actions.ts
"use server"

import { z } from "zod"
import { revalidatePath } from "next/cache"
import { redirect } from "next/navigation"

const productoSchema = z.object({
  nombre: z.string().min(2, "Nombre muy corto"),
  precio: z.number().positive("El precio debe ser positivo"),
})

export async function crearProducto(formData: FormData) {
  const raw = {
    nombre: formData.get("nombre") as string,
    precio: Number(formData.get("precio")),
  }

  const result = productoSchema.safeParse(raw)

  if (!result.success) {
    return { error: result.error.flatten().fieldErrors }
  }

  await db.producto.create({
    data: result.data,
  })

  revalidatePath("/productos")
  redirect("/productos")
}

useActionState para feedback

Para mostrar errores de validacion o estados de loading, usa useActionState:

tsx
"use client"

import { useActionState } from "react"
import { crearProducto } from "@/app/actions"

export default function ProductoForm() {
  const [state, action, pending] = useActionState(crearProducto, null)

  return (
    <form action={action}>
      <div>
        <input name="nombre" placeholder="Nombre" required />
        {state?.error?.nombre && (
          <p className="text-red-400 text-sm">{state.error.nombre}</p>
        )}
      </div>

      <div>
        <input name="precio" type="number" placeholder="Precio" required />
        {state?.error?.precio && (
          <p className="text-red-400 text-sm">{state.error.precio}</p>
        )}
      </div>

      <button type="submit" disabled={pending}>
        {pending ? "Guardando..." : "Crear Producto"}
      </button>
    </form>
  )
}

Revalidacion

Despues de una mutation, revalida los datos para que la UI refleje los cambios:

tsx
"use server"

import { revalidatePath } from "next/cache"
import { revalidateTag } from "next/cache"

export async function eliminarProducto(id: string) {
  await db.producto.delete({ where: { id } })

  // Opcion 1: revalidar una ruta
  revalidatePath("/productos")

  // Opcion 2: revalidar por tag
  revalidateTag("productos")
}

Ejemplo: formulario de contacto completo

tsx
// app/actions/contacto.ts
"use server"

import { z } from "zod"

const contactoSchema = z.object({
  nombre: z.string().min(2),
  email: z.string().email("Email invalido"),
  mensaje: z.string().min(10, "Minimo 10 caracteres"),
})

type State = {
  error?: Record<string, string[]>
  success?: boolean
} | null

export async function enviarContacto(
  prevState: State,
  formData: FormData
): Promise<State> {
  const raw = {
    nombre: formData.get("nombre") as string,
    email: formData.get("email") as string,
    mensaje: formData.get("mensaje") as string,
  }

  const result = contactoSchema.safeParse(raw)
  if (!result.success) {
    return { error: result.error.flatten().fieldErrors }
  }

  // Guardar en DB o enviar email
  await db.contacto.create({ data: result.data })

  return { success: true }
}
tsx
// app/contacto/ContactForm.tsx
"use client"

import { useActionState } from "react"
import { enviarContacto } from "@/app/actions/contacto"

export default function ContactForm() {
  const [state, action, pending] = useActionState(enviarContacto, null)

  if (state?.success) {
    return (
      <p className="text-green-400 text-lg">
        Mensaje enviado. Te respondemos pronto.
      </p>
    )
  }

  return (
    <form action={action} className="space-y-4">
      <div>
        <label className="block text-sm mb-1">Nombre</label>
        <input name="nombre" required className="w-full border rounded px-3 py-2" />
        {state?.error?.nombre && (
          <p className="text-red-400 text-sm mt-1">{state.error.nombre[0]}</p>
        )}
      </div>

      <div>
        <label className="block text-sm mb-1">Email</label>
        <input name="email" type="email" required className="w-full border rounded px-3 py-2" />
        {state?.error?.email && (
          <p className="text-red-400 text-sm mt-1">{state.error.email[0]}</p>
        )}
      </div>

      <div>
        <label className="block text-sm mb-1">Mensaje</label>
        <textarea name="mensaje" rows={4} required className="w-full border rounded px-3 py-2" />
        {state?.error?.mensaje && (
          <p className="text-red-400 text-sm mt-1">{state.error.mensaje[0]}</p>
        )}
      </div>

      <button
        type="submit"
        disabled={pending}
        className="bg-blue-600 text-white px-6 py-2 rounded disabled:opacity-50"
      >
        {pending ? "Enviando..." : "Enviar mensaje"}
      </button>
    </form>
  )
}