Next.js
Route Handlers
Explica cómo crear contratos HTTP con route.ts para APIs, webhooks, descargas y clientes externos, incluyendo validación, status, seguridad y caché.
- Última actualización
- Actualizada
- Nivel
- Aplicación
Next.js
Explica cómo crear contratos HTTP con route.ts para APIs, webhooks, descargas y clientes externos, incluyendo validación, status, seguridad y caché.
Un Route Handler define un contrato HTTP dentro de app mediante Web Request y Response. Es apropiado cuando el consumidor necesita una URL y métodos HTTP explícitos: webhooks, aplicaciones externas, feeds, descargas o APIs públicas. No es una capa obligatoria entre Server Components y la base de datos.
Se crea con route.ts:
// app/api/products/route.ts
export async function GET(request: Request) {
const products = await listPublicProducts();
return Response.json({ data: products });
}Métodos soportados:
GET POST PUT PATCH DELETE HEAD OPTIONSSi el método no existe, Next.js responde 405 Method Not Allowed. Puede generar OPTIONS y Allow según los métodos declarados cuando no defines una implementación propia.
Una Server Action está acoplada al protocolo React/Next.js y a la UI. Un Route Handler expone HTTP estándar:
client / provider / webhook
↓ HTTP
route.ts
↓
validation + auth + service
↓
ResponseEsto permite consumidores no React y contratos documentables.
app/api/orders/route.ts → /api/orders
app/api/orders/[id]/route.ts → /api/orders/:idNo puede existir page.tsx y route.ts para el mismo segmento final. Ambas convenciones intentarían controlar la misma URL.
route.ts puede vivir fuera de /api, pero usar el prefijo ayuda a distinguir endpoints de páginas.
export async function POST(request: Request) {
const contentType = request.headers.get("content-type");
const body = await request.json();
}Añade helpers como nextUrl y cookies:
import type { NextRequest } from "next/server";
export function GET(request: NextRequest) {
const query = request.nextUrl.searchParams.get("query");
}Prefiere Web APIs cuando bastan; usa helpers de Next.js por una necesidad concreta.
return Response.json(
{ data: order },
{ status: 200 },
);NextResponse ayuda con cookies, rewrites o redirects:
const response = NextResponse.json({ ok: true });
response.cookies.set("session", token, cookieOptions);
return response;No llames request.json() sin verificar formato/tamaño cuando el endpoint es público:
if (!request.headers.get("content-type")?.includes("application/json")) {
return problem(415, "unsupported_media_type", "Use application/json");
}
const raw = await request.json().catch(() => null);
const parsed = schema.safeParse(raw);
if (!parsed.success) {
return problem(400, "invalid_request", "La solicitud no es válida", {
fields: parsed.error.flatten().fieldErrors,
});
}La plataforma puede imponer un límite de body; define además límites de negocio.
export async function GET(
_request: Request,
{ params }: { params: Promise<{ orderId: string }> },
) {
const { orderId } = await params;
const parsed = orderIdSchema.safeParse(orderId);
if (!parsed.success) return problem(400, "invalid_id", "ID inválido");
}El nombre de carpeta solo captura string. Valida y autoriza.
200: lectura/actualización exitosa.201: recurso creado.202: trabajo aceptado para procesamiento asíncrono.204: éxito sin body.400: input mal formado.401: falta autenticación válida.403: autenticado sin permiso.404: recurso ausente o existencia ocultada.409: conflicto/duplicado/concurrencia.415: tipo de contenido no soportado.422: semántica inválida si esa convención forma parte del contrato.429: rate limit.500: fallo inesperado.No respondas 200 con { error: true }; rompe clientes, caches y observabilidad.
type Problem = {
type: string;
title: string;
status: number;
detail?: string;
instance?: string;
errors?: Record<string, string[]>;
};Un formato estable permite que clientes manejen errores sin parsear textos.
No expongas stack, SQL o secretos.
const session = await authenticateRequest(request);
if (!session) return problem(401, "unauthorized", "Debes iniciar sesión");
const order = await orderRepository.findAccessible({
orderId,
organizationId: session.organizationId,
userId: session.user.id,
});
if (!order) return problem(404, "not_found", "Pedido no encontrado");Proxy puede hacer un filtro temprano, pero el Handler vuelve a autorizar.
CORS controla qué orígenes de navegador pueden leer la respuesta; no autentica:
const allowedOrigin = getAllowedOrigin(request.headers.get("origin"));
return new Response(null, {
status: 204,
headers: {
"Access-Control-Allow-Origin": allowedOrigin,
"Access-Control-Allow-Methods": "GET,POST,OPTIONS",
"Access-Control-Allow-Headers": "Content-Type,Authorization",
Vary: "Origin",
},
});No uses * con credenciales. Valida origen mediante allowlist.
Clientes servidor-a-servidor no están protegidos por CORS.
Necesitas body original para firma:
export async function POST(request: Request) {
const rawBody = await request.text();
const signature = request.headers.get("x-provider-signature");
if (!verifySignature(rawBody, signature)) {
return new Response("Invalid signature", { status: 401 });
}
const event = webhookSchema.parse(JSON.parse(rawBody));
await processEventIdempotently(event);
return new Response(null, { status: 204 });
}Protege:
Responde rápido y mueve trabajo pesado a cola.
Los Route Handlers no se cachean automáticamente como una regla universal. Un GET puede usar Cache Components o headers HTTP según el resultado.
No caches:
Vary/key correcta.Para una API pública:
return Response.json(data, {
headers: {
"Cache-Control": "public, s-maxage=300, stale-while-revalidate=3600",
},
});Comprueba la semántica de la plataforma y CDN.
export async function GET() {
const stream = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode("first\n"));
controller.close();
},
});
return new Response(stream, {
headers: { "Content-Type": "text/plain; charset=utf-8" },
});
}Útil para NDJSON, archivos o respuestas progresivas. Considera cancelación y backpressure.
return new Response(fileStream, {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": 'attachment; filename="invoice.pdf"',
},
});Evita cargar archivos grandes completos en memoria. Autoriza antes y normaliza el filename para prevenir header injection.
Para archivos pequeños puedes leer request.formData(). Para grandes, usa upload directo firmado a object storage.
Valida tamaño, tipo real, cuota, malware y ownership.
Una variable global en memoria no funciona de forma confiable en serverless/múltiples réplicas. Usa un store compartido o gateway.
Key probable:
user ID / API key / trusted IP + endpoint + windowNo confíes ciegamente en headers de IP si la plataforma no los firma/controla.
POST de pagos/creación debe aceptar una key:
const key = request.headers.get("idempotency-key");Guarda key + actor + operation y devuelve la respuesta previa ante retry.
PUT suele expresar reemplazo idempotente; PATCH depende de la operación.
Node.js es default. Route Segment Config puede seleccionar runtime cuando Cache Components no lo deshabilita, pero compatibilidad depende de paquetes/plataforma.
Configura timeouts y tamaño. Jobs largos pertenecen a workers/colas, no a una request web.
POST /api/v1/orders
→ auth API key
→ validate body
→ authorize tenant
→ idempotency
→ transaction
→ enqueue fulfillment
→ 201 + LocationGET lista usa cursor y límites. PATCH usa version para evitar lost updates. Errores siguen un Problem contract.
No hagas fetch("/api/internal") desde Server Components cuando puedes llamar el servicio directamente.
Prueba la función de dominio separada y el adaptador HTTP.
200 para errores./api/orders desde una page servidor propia?Diseño de APIs dentro de Next.js convierte handlers aislados en contratos consistentes, versionados y operables.