Server Action vs Route Handler en Next.js: cuál usar y cuándo
Ambos ejecutan código en el servidor. La diferencia real no está en la sintaxis, está en quién más va a consumir esa lógica además de tu propia UI.
Necesitas ejecutar código en el servidor desde tu app Next.js: guardar un formulario, procesar un pago, actualizar un registro. Tienes dos caminos disponibles — una Server Action o un Route Handler — y ambos técnicamente “funcionan” para casi cualquier caso. Eso es justo lo que hace la decisión confusa: la sintaxis no te obliga a elegir bien, tienes que entender la diferencia de fondo.
La pregunta que sí importa
No es “¿cuál es más rápido de escribir?”. Es: ¿quién más, además de mi propia UI en React, va a necesitar invocar esta lógica?
- Si la respuesta es “nadie más, solo mis formularios y botones dentro de esta misma app” → Server Action.
- Si la respuesta es “un webhook externo, una app móvil, un tercero, o necesito una URL pública con un método HTTP específico” → Route Handler.
Server Actions: funciones del servidor, invocables como si fueran locales
// src/actions/cart.actions.ts
'use server'
import { revalidatePath } from 'next/cache'
import { prisma } from '@/lib/prisma'
export async function addToCart(productId: string, quantity: number = 1) {
await prisma.cartItem.upsert({
where: { cartId_productId: { cartId: 'xxx', productId } },
update: { quantity: { increment: quantity } },
create: { cartId: 'xxx', productId, quantity },
})
revalidatePath('/carrito')
}
Lo que hace especial a una Server Action no es solo que corre en el servidor — es que Next.js genera automáticamente el mecanismo de comunicación (un endpoint interno, invisible para ti) para que puedas llamarla como si fuera una función normal de JavaScript, directo desde un Client Component:
'use client'
import { addToCart } from '@/actions/cart.actions'
export function AddToCartButton({ productId }: { productId: string }) {
return <button onClick={() => addToCart(productId)}>Agregar</button>
}
No escribiste fetch(), no definiste una ruta, no manejaste el Content-Type. Eso es progressive enhancement gratis, además: un <form action={miServerAction}> funciona incluso si el JavaScript del cliente todavía no cargó.
Route Handlers: URLs HTTP reales, con control total del protocolo
// src/app/api/webhooks/pagos/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
export async function POST(request: NextRequest) {
const signature = request.headers.get('x-webhook-signature')
// validar firma del webhook...
const body = await request.json()
await prisma.order.update({
where: { id: body.orderId },
data: { status: 'paid' },
})
return NextResponse.json({ received: true })
}
Esto sí es una URL real (/api/webhooks/pagos), con un método HTTP explícito (POST), headers que puedes leer directamente, y un cuerpo de respuesta que controlas por completo. Un proveedor de pagos externo puede hacerle POST sin saber ni le importa que tu frontend esté hecho en Next.js.
La tabla que resume la decisión
| Necesitas… | Usa |
|---|---|
| Un formulario o botón que solo tu propia UI React invoca | Server Action |
| Progressive enhancement (formularios que funcionan sin JS) | Server Action |
| Un webhook que un tercero (Stripe, un CRM, etc.) va a llamar | Route Handler |
| Una API consumida por una app móvil o cliente externo | Route Handler |
| Control fino sobre headers, status codes, streaming de respuesta | Route Handler |
Mutación simple ligada a una Server Action con useActionState | Server Action |
| Un proxy hacia un backend externo (ocultar API keys) | Route Handler |
El error común: usar Route Handler donde bastaba una Server Action
Es habitual ver código de gente que viene de un mundo REST puro, construyendo un endpoint /api/cart/add con su propio fetch() desde el cliente, cuando una Server Action hubiera sido más simple, con menos código, y con progressive enhancement de regalo:
// Innecesariamente más código, si nadie más que tu propia UI lo consume
export async function POST(request: NextRequest) {
const { productId, quantity } = await request.json()
// misma lógica que la Server Action de arriba...
return NextResponse.json({ success: true })
}
'use client'
async function handleClick() {
await fetch('/api/cart/add', {
method: 'POST',
body: JSON.stringify({ productId, quantity }),
})
}
Funciona, pero es más código, sin ganar nada — porque nadie fuera de tu propia UI necesita esta URL como endpoint público.
El error inverso: forzar una Server Action donde tocaba un Route Handler
También pasa al revés. Si tu lógica de “agregar al carrito” también necesita ser invocada desde una app móvil nativa, una Server Action no te sirve — está pensada para invocarse desde componentes React de esta misma aplicación, no como un endpoint HTTP documentable y consumible desde cualquier cliente.
Un patrón útil: Server Action que llama a la misma lógica que el Route Handler
Cuando ambos casos coexisten (tu web usa Server Actions, pero también necesitas exponer la misma operación como API pública), la solución limpia es extraer la lógica de negocio a una función compartida, y que tanto la Server Action como el Route Handler la invoquen:
// src/lib/cart-logic.ts — lógica compartida, sin 'use server' ni handlers HTTP
export async function addItemToCart(cartId: string, productId: string, quantity: number) {
return prisma.cartItem.upsert({
where: { cartId_productId: { cartId, productId } },
update: { quantity: { increment: quantity } },
create: { cartId, productId, quantity },
})
}
// Server Action: la usa tu propia UI
'use server'
import { addItemToCart } from '@/lib/cart-logic'
export async function addToCart(productId: string, quantity: number) {
const cartId = await getCartId()
await addItemToCart(cartId, productId, quantity)
revalidatePath('/carrito')
}
// Route Handler: lo usa, por ejemplo, tu app móvil
import { addItemToCart } from '@/lib/cart-logic'
export async function POST(request: NextRequest) {
const { cartId, productId, quantity } = await request.json()
await addItemToCart(cartId, productId, quantity)
return NextResponse.json({ success: true })
}
Conclusión
No hay una opción “mejor” en abstracto — hay una pregunta de arquitectura que resuelve la decisión sola: ¿quién más va a consumir esto?. Si la respuesta se mantiene “solo mi propia UI”, elige Server Action y disfruta del código más simple y el progressive enhancement gratis. En el momento en que alguien externo a tu app React entra en la ecuación, necesitas un Route Handler.
¿Tienes dudas sobre cómo estructurar las mutaciones y APIs de tu proyecto Next.js? Agenda una asesoría técnica gratuita de 15 minutos.
¿Tienes un proyecto en mente?
Convierte tu idea en un producto real
Desarrollo web, aplicaciones a medida y consultoría tecnológica para empresas y startups. Cuéntame tu proyecto y te respondo en menos de 24 horas.