¿Acciones de servidor o controladores de ruta? Una guía de decisión para Next.js 16
Aprenda cuándo una mutación de Next.js 16 debe colocarse en una acción del servidor y cuándo necesita un manejador de ruta, con ejemplos corregidos para formularios, errores, optimismo y webhooks.
Las Acciones del Servidor se introdujeron en Next.js 13.4 como atajo para los envíos de formularios y ahora son una primitiva fundamental del App Router; sin embargo, cada nueva función plantea la misma pregunta: ¿acción o ruta API? Aquí hay un modelo mental para tomar esa decisión, ambos patrones en código, y soluciones a conceptos erróneos que causan errores reales.
Dos contratos diferentes
Rutas API, denominadas Manejadores de Ruta en el App Router (app/api/.../route.ts), son puntos finales HTTP ordinarios. Tienen URLs estables a las que cualquier cliente puede acceder, control total sobre códigos de estado y encabezados de caché, y no tienen relación con tus componentes.
Acciones del Servidor son funciones 'use server' que se invocan desde tu código React. Next.js genera el punto final, serializa los argumentos y resultados, y los integra en formularios, transiciones y una interfaz de usuario optimista, con tipos que se transmiten al lugar donde se realiza la llamada.
A diferencia de lo que se afirma con frecuencia, las Acciones del Servidor no son privadas: cada una es un punto de extremo POST identificado por un ID de acción, y cualquiera que posea ese ID puede invocarla con argumentos arbitrarios.
Regla general: los llamantes externos utilizan un Manejador de Ruta; las mutaciones activadas por la propia interfaz de usuario suelen corresponder a una Acción del Servidor.
Acciones del Servidor en la práctica
Mutación mediante formulario
El archivo comienza con la directiva que marca cada exportación como una función del servidor:
// app/actions/order.ts
'use server'
La acción se autentica, valida los campos, escribe el pedido, vuelve a validar /orders y redirige. auth() lee las cookies de la solicitud, por lo que el cliente no envía ningún token.
import { auth } from '@/lib/auth'
import { db } from '@/lib/db'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'export async function createOrder(formData: FormData) {
// Auth is automatic — no need to pass session
const session = await auth()
if (!session?.user) throw new Error('Unauthorized') const item = formData.get('item') as string
const quantity = Number(formData.get('quantity')) // Validate
if (!item || quantity < 1) {
return { error: 'Invalid order data' }
} // Database write
const order = await db.order.create({
data: { item, quantity, userId: session.user.id },
}) // Invalidate cached data
revalidatePath('/orders') // Redirect after success
redirect(`/orders/${order.id}`)
}
Tenga en cuenta que redirect funciona mediante lanzamiento de excepciones, por lo que nunca debe llamarse dentro de un bloque try que absorba errores. La página importa la acción:
// app/shop/page.tsx
import { createOrder } from '@/app/actions/order'
y lo pasa directamente a la propiedad action de un formulario:
export default function ShopPage() {
return (
<form action={createOrder}>
<input name="item" type="text" placeholder="Item name" />
<input name="quantity" type="number" defaultValue={1} />
<button type="submit">Order</button>
</form>
)
}
No hay estado del cliente, efecto ni fetch, y el formulario se envía incluso antes de que se cargue JavaScript.
Devolver errores con useActionState
useActionState (React 19) almacena el último valor devuelto por la acción y una bandera de pendiente:
'use client'
import { useActionState } from 'react'
import { createOrder } from '@/app/actions/order'
export function OrderForm() {
const [state, action, isPending] = useActionState(createOrder, null) return (
<form action={action}>
{state?.error && (
<p style={{ color: 'red' }}>{state.error}</p>
)}
<input name="item" />
<input name="quantity" type="number" />
<button type="submit" disabled={isPending}>
{isPending ? 'Ordering...' : 'Place Order'}
</button>
</form>
)
}
Un problema: cuando una acción está envuelta en useActionState, recibe el estado anterior como su primer argumento y FormData como segundo. La firma de createOrder mostrada anteriormente solo acepta formData, por lo que para este formulario debe convertirse en createOrder(prevState, formData).
Feedback optimista con useOptimistic
useOptimistic muestra el resultado esperado hasta que la acción se resuelva:
'use client'
import { useOptimistic } from 'react'
import { toggleLike } from '@/app/actions/post'
export function LikeButton({ postId, initialLikes }: { postId: string; initialLikes: number }) {
const [optimisticLikes, setOptimistic] = useOptimistic(initialLikes) async function handleLike() {
setOptimistic(prev => prev + 1) // UI updates instantly
await toggleLike(postId) // Server call happens in background
} return (
<button onClick={handleLike}>
❤️ {optimisticLikes}
</button>
)
}
El setter optimista debe ejecutarse dentro de una transición o acción. Desde un onClick simple, envuelva el cuerpo en startTransition; de lo contrario, React emitirá advertencias y el estado optimista no funcionará como se espera. Las trampas de reversión están explicadas en cinco modos de fallo de useOptimistic.
Cuándo usar un Route Handler
Este endpoint versionado muestra sus ventajas: parámetros de consulta, caché en CDN y autenticación con clave API.
// app/api/v1/products/route.ts
import { NextRequest, NextResponse } from 'next/server'
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const category = searchParams.get('category') const products = await db.product.findMany({
where: category ? { category } : undefined,
}) return NextResponse.json({ products }, {
headers: {
'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=300',
}
})
}export async function POST(request: NextRequest) {
const apiKey = request.headers.get('x-api-key')
if (apiKey !== process.env.API_KEY) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} const body = await request.json()
const product = await db.product.create({ data: body })
return NextResponse.json({ product }, { status: 201 })
}
Elija un Route Handler cuando:
- las aplicaciones móviles consumen el endpoint;
- un tercero envía webhooks;
- las respuestas necesitan sus propios encabezados de caché;
- se trata de una API o producto API público y versionado.
En producción, valide el cuerpo de la solicitud POST en lugar de pasarlo a la base de datos sin verificarlo.
Cómo se comparan los dos
- Viajes ida y vuelta: ambos requieren una solicitud desde el navegador hasta su servidor; ninguno es de servidor a servidor.
- Bundlado del cliente: la lógica permanece en el servidor en ambos casos.
- Autenticación: las acciones leen automáticamente la sesión de las cookies; los manejadores que atienden a otros clientes verifican tokens o claves por solicitud.
- Tipos: se comparten de extremo a extremo con las acciones; los manejadores necesitan un cliente o esquema tipado.
- Caché: las acciones invalidan con
revalidatePatho etiquetas; los manejadores establecenCache-Control. - UI optimista: está integrado en los hooks de React para las acciones; es manual para los manejadores.
En el momento de escribir esto, Next.js también envía acciones del servidor desde el cliente una por una, por lo que son adecuadas para mutaciones en lugar de lecturas paralelas de datos; consulte la documentación actual.
Lista de verificación para tomar decisiones
Comience con la pregunta que resuelve la mayoría de los casos:
Does an external system call this?
YES → API Route
Luego proceda con el resto:
Is this triggered by a form submit or user action within your UI?
YES → Server ActionDo you need explicit HTTP caching headers?
YES → API RouteDo you want automatic auth context without passing tokens?
YES → Server ActionDo you need to call this from a mobile app?
YES → API RouteEverything else?
→ Server Action (less boilerplate)
Dos errores que evitar
Dirigir webhooks a una acción del servidor
Stripe envía datos a una URL registrada y no puede apuntar a un ID de acción.
// ❌ Wrong — Server Actions can't receive arbitrary HTTP POSTs from Stripe
'use server'
export async function handleStripeWebhook() { ... }
Los webhooks deben encontrarse en un manejador de rutas, donde se puede leer el cuerpo sin procesar y verificar la firma.
// ✅ Correct
// app/api/webhooks/stripe/route.ts
export async function POST(request: NextRequest) { ... }
Confiar en las entradas de la acción
Dado que cualquier acción puede ser invocada directamente, una acción de eliminación sin verificaciones permite a cualquiera eliminar cualquier cosa:
// ❌ Wrong — Server Actions are not inherently trusted
'use server'
export async function deletePost(postId: string) {
await db.post.delete({ where: { id: postId } }) // Anyone can call this!
}
La solución verifica la sesión y la propiedad antes de escribir, y luego vuelve a validar:
// ✅ Correct — always validate auth + ownership
'use server'
export async function deletePost(postId: string) {
const session = await auth()
const post = await db.post.findUnique({ where: { id: postId } })
if (post?.userId !== session?.user?.id) throw new Error('Forbidden')
await db.post.delete({ where: { id: postId } })
revalidatePath('/posts')
}
También rechace explícitamente las sesiones faltantes. Consulte nuestro artículo en la autorización dentro de cada acción del servidor, que aborda este tema en profundidad.
Puntos clave
- Cada acción del servidor es un punto de extremo al que se puede acceder; autentique, autorice y valide dentro de ella.
- Con
useActionState, la acción recibe primero el estado anterior. - Ejecute las actualizaciones con
useOptimisticdentro de una transición.
Lecturas relacionadas
- Rollback con useOptimistic: Cinco modos de fallo en las acciones del servidor de Next.js — Aprenda por qué useOptimistic reversiona silenciosamente la interfaz de usuario sin explicar los fallos a los usuarios, a través de cinco modos de fallo probados en las acciones del servidor y una solución funcional.