Inicio / Artículos / ¿Acciones de servidor o controladores de ruta? Una guía de decisión para Next.js 16

¿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.

1558 palabras

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 revalidatePath o etiquetas; los manejadores establecen Cache-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 useOptimistic dentro de una transición.

Lecturas relacionadas

  • Por qué las acciones del servidor de Next.js necesitan autorización dentro de cada cuerpo de función — Un caso detallado de apropiación no autorizada muestra cómo las acciones del servidor de Next.js sin autenticación exponen operaciones privilegiadas, y dónde debe realizarse la verificación de autorización para evitarlo.
  • Triaje de la actualización a React 19: ¿Qué nuevas API sustituyen a las soluciones temporales que tienes? — Una visión práctica del uso de Server Actions, useOptimistic y el React Compiler, con las consideraciones importantes y un plan para adoptar primero qué cambios de React 19.
  • Dónde dibujar la línea servidor-cliente en una página del App Router de Next.js — Un modelo mental práctico para los Componentes Servidor de React: qué se ejecuta dónde, cómo una página de artículo de blog se divide en partes servidor y cliente, y las reglas de importación a seguir.
  • Next.js o React simple? Combinar el marco con su proyecto — Una forma basada en requisitos para decidir si un nuevo proyecto React necesita Next.js, abarcando SEO, renderizado híbrido, límites de hosting, necesidades de enrutamiento y experiencia del equipo.
  • Una única fuente de verdad para las rutas en aplicaciones Next.js — Aprenda cómo un módulo central de rutas tipado reemplaza las rutas en formato cadena dispersas en Next.js, con funciones auxiliares para segmentos dinámicos y exportaciones de rutas agrupadas.
  • Por qué WebSockets se atascan en las rutas API de Next.js y cómo un servidor personalizado lo soluciona — Entienda por qué un servidor ws dentro de una ruta pages/api nunca completa el proceso de conexión, y cómo manejar el evento de actualización por sí mismo con un servidor personalizado de Next.js.