Las Server Actions están diseñadas y optimizadas para mutaciones de interfaz de usuario y procesamiento de formularios en estrecha integración con React Server Components. Sin embargo, las arquitecturas web modernas requieren con frecuencia interfaces RESTful públicas y estandarizadas accesibles para sistemas externos: clientes móviles, microservicios de terceros, webhooks entrantes (Stripe, GitHub, Slack) o generación de archivos dinámicos (canales RSS, sitemaps XML, imágenes dinámicas Open Graph).
Para crear endpoints HTTP aislados en el App Router de Next.js se emplean los Route Handlers. Como sucesores de las antiguas API Routes del Pages Router, los Route Handlers proporcionan un control exhaustivo sobre la capa de transporte de red: métodos HTTP, códigos de estado, cabeceras, mutación de cookies y streaming binario de datos.
Los Route Handlers se ejecutan exclusivamente en el servidor dentro de un contexto aislado, lo que garantiza la seguridad en la comunicación con bases de datos, protege las variables de entorno privadas y no sobrecarga el bundle de JavaScript del cliente.
En el App Router de Next.js, el enrutamiento está vinculado estrictamente a la estructura del sistema de archivos:
page.tsx gestionan la interfaz visual y retornan React Server Components (HTML y RSC Flight Payload).route.ts gestionan el acceso programático a datos y devuelven instancias nativas de Response (JSON, flujos binarios, XML).[!IMPORTANT] Conflicto de rutas: Un archivo
route.tsno puede coexistir en el mismo nivel de directorio con un archivopage.tsx. Un segmento de URL no puede resolver al mismo tiempo una página HTML y una respuesta de datos de API cruda. Crear/users/page.tsxy/users/route.tsen la misma carpeta provocará un error de compilaciónRoute conflict error.
Dentro de un archivo route.ts se exportan funciones asíncronas con nombres que corresponden a los métodos HTTP estándar: GET, POST, PUT, PATCH, DELETE, HEAD y OPTIONS.
Next.js adopta los estándares web nativos de la Fetch API —las interfaces Request y Response— enriqueciéndolos con las utilidades de servidor NextRequest y NextResponse.
El framework analiza la URL entrante y resuelve el árbol de directorios hacia route.ts.
Si detecta conflicto entre route.ts y page.tsx en la misma carpeta, la compilación se detiene con error.
La petición se despacha al entorno configurado: Node.js (por defecto) o Edge V8 runtime.
Los parámetros de ruta se inyectan en el contexto del handler como una Promise asíncrona.
Next.js comprueba el método entrante (GET, POST, OPTIONS) frente a las funciones exportadas.
Si el método solicitado no está exportado en el módulo, se responde automáticamente con 405 Method Not Allowed.
Se leen las cabeceras asíncronamente (await headers()) y se verifican tokens y firmas.
En webhooks se lee el cuerpo crudo mediante await req.text() para no invalidar firmas HMAC.
Se consultan bases de datos o se transmiten fragmentos a través de un ReadableStream.
Se retorna una instancia de Response o NextResponse con cabeceras y códigos de estado personalizados.
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { users } from '@/lib/schema';
// Manejador para solicitudes GET
export async function GET(request: NextRequest) {
// Lectura de parámetros de consulta mediante la propiedad nextUrl
const searchParams = request.nextUrl.searchParams;
const limit = Math.min(Number(searchParams.get('limit') ?? '10'), 50);
const userList = await db.query.users.findMany({
limit,
});
return NextResponse.json(userList, {
status: 200,
headers: {
'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=120',
},
});
}
// Manejador para solicitudes POST
export async function POST(request: NextRequest) {
try {
// Análisis asíncrono y seguro del cuerpo JSON
const body: unknown = await request.json();
if (!body || typeof body !== 'object' || !('name' in body)) {
return NextResponse.json(
{ error: 'Estructura de datos inválida' },
{ status: 400 }
);
}
const newUser = await db.insert(users).values(body).returning();
return NextResponse.json(newUser[0], { status: 201 });
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Internal Server Error';
return NextResponse.json({ error: message }, { status: 500 });
}
}En Next.js 15 y 16, el objeto de contexto params es una Promise asíncrona. El acceso síncrono directo (params.id) está desaconsejado, provoca advertencias y quiebra la seguridad de tipos.
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { users } from '@/lib/schema';
import { eq } from 'drizzle-orm';
interface RouteContext {
params: Promise<{
id: string;
}>;
}
export async function DELETE(
_request: NextRequest,
{ params }: RouteContext
) {
// Resolución asíncrona obligatoria de parámetros de ruta en Next.js 15+
const { id: userId } = await params;
if (!userId) {
return NextResponse.json({ error: 'El ID de usuario es obligatorio' }, { status: 400 });
}
await db.delete(users).where(eq(users.id, userId));
// Retorno de respuesta vacía exitosa 204 No Content
return new Response(null, { status: 204 });
}Next.js 15+ requiere resolver de manera asíncrona las utilidades cookies() y headers() de next/headers:
import { cookies, headers } from 'next/headers';
import { NextRequest, NextResponse } from 'next/server';
export async function GET(request: NextRequest) {
// 1. Lectura asíncrona de cabeceras del servidor
const headerStore = await headers();
const authorization = headerStore.get('authorization');
// 2. Acceso asíncrono a cookies
const cookieStore = await cookies();
const theme = cookieStore.get('theme')?.value ?? 'dark';
// 3. Obtención de IP del cliente mediante NextRequest
const clientIp = request.headers.get('x-forwarded-for') ?? '127.0.0.1';
// Creación de respuesta y establecimiento de nueva cookie de sesión
const response = NextResponse.json({
authorized: Boolean(authorization),
theme,
clientIp,
});
response.cookies.set('session_active', 'true', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24, // 24 horas
path: '/',
});
return response;
}¿Por qué está estrictamente prohibido invocar await req.json() antes de verificar la firma de un webhook de Stripe en un Route Handler, siendo obligatorio el uso de await req.text()?
Cuando una API es consumida desde navegadores en dominios externos, es indispensable gestionar CORS mediante un método OPTIONS.
const CORS_HEADERS = {
'Access-Control-Allow-Origin': 'https://dashboard.example.com',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
};
// Petición preflight enviada por el navegador antes de la solicitud real
export async function OPTIONS() {
return new Response(null, {
status: 204,
headers: CORS_HEADERS,
});
}
export async function GET() {
return Response.json(
{ status: 'healthy', timestamp: Date.now() },
{
status: 200,
headers: CORS_HEADERS,
}
);
}Los Route Handlers son compatibles de forma nativa con el estándar Web Streams API, lo que facilita el streaming de tokens de modelos de lenguaje o la transferencia por partes de archivos pesados.
export async function GET() {
const encoder = new TextEncoder();
// Inicialización de un ReadableStream nativo
const stream = new ReadableStream({
async start(controller) {
const chunks = ['Flujo ', 'de datos ', 'generado ', 'en tiempo ', 'real.\n'];
for (const chunk of chunks) {
controller.enqueue(encoder.encode(chunk));
// Simulación de latencia de red entre fragmentos
await new Promise((resolve) => setTimeout(resolve, 300));
}
controller.close();
},
});
return new Response(stream, {
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'Transfer-Encoding': 'chunked',
},
});
}Para validar la firma criptográfica de un webhook, jamás analices el cuerpo con JSON antes de la verificación. Cualquier alteración de formato arruina el hash HMAC.
import { headers } from 'next/headers';
import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY ?? '', {
apiVersion: '2025-02-24.acacia',
});
export async function POST(req: NextRequest) {
// Para verificar la firma HMAC se requiere la cadena exacta de bytes
const payload = await req.text();
const headerStore = await headers();
const signature = headerStore.get('stripe-signature');
if (!signature) {
return new Response('Falta la cabecera de firma', { status: 400 });
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
payload,
signature,
process.env.STRIPE_WEBHOOK_SECRET ?? ''
);
} catch (err: unknown) {
const errMsg = err instanceof Error ? err.message : 'Unknown signature error';
return new Response(`Fallo de verificación de webhook: ${errMsg}`, { status: 400 });
}
if (event.type === 'payment_intent.succeeded') {
const paymentIntent = event.data.object as Stripe.PaymentIntent;
// Lógica de procesamiento del pago...
}
return NextResponse.json({ received: true }, { status: 200 });
}En un Server Component situado en app/dashboard/page.tsx, un desarrollador escribe: const res = await fetch('http://localhost:3000/api/metrics'). ¿Por qué esta práctica representa un grave antipatrón de arquitectura?
El cuerpo de la petición entrante request.body es un Locked Stream. Una vez ejecutado un método de lectura (como await request.json()), el flujo queda bloqueado (bodyUsed = true).
Cualquier intento posterior de leer el cuerpo (en middlewares de registro o auditorías) arroja la excepción TypeError: Body has already been consumed or is locked.
Solución: Almacena el resultado de la primera lectura en una variable local o clona la petición (req.clone()) antes de consumirla.
// ❌ ERROR: Consumo duplicado del flujo del cuerpo
export async function POST(req: Request) {
const text = await req.text();
const json = await req.json(); // Lanza TypeError fatal: Body already consumed!
}
// ✅ CORRECTO: Guardar el texto en una variable y parsearlo si es necesario
export async function POST(req: Request) {
const rawText = await req.text();
const parsedData = JSON.parse(rawText);
}En Next.js 13 y 14 los parámetros de ruta se recibían como un objeto síncrono { params: { id: string } }. En Next.js 15 y 16 la propiedad params es estrictamente una Promise asíncrona. Acceder directamente a params.id sin un previo await params genera advertencias en el compilador y rompe la seguridad de tipos en tiempo de ejecución.
Con frecuencia, desarrolladores con poca experiencia crean un endpoint route.ts con el único propósito de invocar fetch('http://localhost:3000/api/data') dentro de page.tsx.
Esto genera una sobrecarga de red innecesaria (una llamada HTTP loopback a través del stack de red), agota sockets del servidor e incrementa el Time to First Byte (TTFB). Cuando un Server Component necesita datos, encapsula la lógica en una Capa de Acceso a Datos (DAL) e invócala como una función convencional de TypeScript.