Busca en todas las páginas de la documentación
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
Construye puntos de conexión de API REST seguros en tipos usando Next.js 15+ App Router Route Handlers con NextRequest, NextResponse, validación de solicitud y códigos de estado HTTP apropiados.
// app/api/posts/route.ts
import { NextRequest, NextResponse } from "next/server";
type Post = {
id: string;
title: string;
content: string;
createdAt: string;
};
// GET /api/posts
export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl;
const page = parseInt(searchParams.get("page") ?? "1", 10);
const limit = parseInt(searchParams.get("limit") ?? "10", 10);
const posts = await db.post.findMany({
skip: (page - 1) * limit,
take: limit,
orderBy: { createdAt: "desc" },
});
return NextResponse.json({ posts, page, limit });
}
// POST /api/posts
export async function POST(request: NextRequest) {
const body = await request.json();
if (!body.title || !body.content) {
return NextResponse.json(
{ error: "title and content are required" },
{ status: 400 }
);
}
const post = await db.post.create({
data: { title: body.title, content: body.content },
});
return NextResponse.json(post, { status: 201 });
}// app/api/posts/[id]/route.ts
import { NextRequest, NextResponse } from "next/server";
type Params = { params: Promise<{ id: string }> };
// GET /api/posts/:id
export async function GET(request: NextRequest, { params }: Params) {
const { id } = await params;
const post = await db.post.findUnique({ where: { id } });
if (!post) {
return NextResponse.json({ error: "Post not found" }, { status: 404 });
}
return NextResponse.json(post);
}
// PATCH /api/posts/:id
export async function PATCH(request: NextRequest, { params }: Params) {
const { id } = await params;
const body = await request.json();
const post = await db.post.update({
where: { id },
data: body,
});
return NextResponse.json(post);
}
// DELETE /api/posts/:id
export async function DELETE(request: NextRequest, { params }: Params) {
const { id } = await params;
await db.post.delete({ where: { id } });
return new NextResponse(null, { status: 204 });
}// app/api/auth/me/route.ts
import { NextRequest, NextResponse } from "next/server";
import { verifySession } from "@/lib/auth";
export async function GET(request: NextRequest) {
const token = request.cookies.get("session-token")?.value;
if (!token) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const session = await verifySession(token);
if (!session) {
return NextResponse.json({ error: "Invalid session" }, { status: 401 });
}
const response = NextResponse.json({ user: session });
response.headers.set("X-Request-Id", crypto.randomUUID());
return response;
}GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS) desde un archivo route.ts dentro del directorio app.NextRequest extiende la Request estándar con propiedades de conveniencia: .nextUrl para URL analizada, .cookies para acceso a cookies, y .geo / .ip en despliegues edge.NextResponse extiende Response con ayudantes estáticos: .json(), .redirect() y .rewrite().{ params: Promise<{ ... }> }, y debes await params antes de acceder a los valores.GET sin entrada dinámica. Usa request.nextUrl.searchParams, cookies() o headers() para optar por el renderizado dinámico automáticamente.route.ts en el mismo directorio que page.tsx causará conflicto. Los Route Handlers y las páginas no pueden coexistir en el mismo segmento de ruta.Respuesta de Streaming:
export async function GET() {
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
for (const chunk of ["Hello", " ", "World"]) {
controller.enqueue(encoder.encode(chunk));
await new Promise((r) => setTimeout(r, 100));
}
controller.close();
},
});
return new Response(stream, {
headers: { "Content-Type": "text/plain" },
});
}Manejo de Datos de Formulario:
export async function POST(request: NextRequest) {
const formData = await request.formData();
const file = formData.get("file") as File;
if (!file) {
return NextResponse.json({ error: "No file uploaded" }, { status: 400 });
}
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);
// Guarda buffer al almacenamiento...
return NextResponse.json({ filename: file.name, size: file.size });
}Encabezados CORS:
export async function OPTIONS() {
return new NextResponse(null, {
status: 204,
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
},
});
}Promise<{ id: string }> en lugar del antiguo sincrónico { id: string }.request.json() devuelve Promise<any>. Moldea o valida el resultado con Zod para seguridad en tipos.NextRequest (no Request) para acceder a .nextUrl, .cookies y otras extensiones de Next.js.GET sin entrada dinámica se almacenan en caché estáticamente en tiempo de compilación. Accede a searchParams, cookies() o headers() para hacerlos dinámicos, o exporta const dynamic = "force-dynamic".route.ts y page.tsx no pueden compartir el mismo directorio. El Route Handler sombreará la página. Coloca rutas API bajo app/api/ para evitar conflictos.request.json() lanza en cuerpos vacíos o con formato incorrecto. Enrólalo en try/catch para seguridad.new Response(null, { status: 204 }) se requiere para respuestas sin contenido. NextResponse.json(null, { status: 204 }) enviará un cuerpo JSON vacío, no una respuesta verdadera sin contenido.fs, Buffer o crypto (usa globalThis.crypto en su lugar).| Enfoque | Ventajas | Desventajas |
|---|---|---|
| Route Handlers | Nativo, cero configuración, colocalizado | Sin validación incorporada o cadena middleware |
| Server Actions | Sin ruta API necesaria, amigable con formularios | No RESTful, difícil de llamar externamente |
| tRPC | Seguridad de tipos de extremo a extremo | Dependencia extra, curva de aprendizaje |
| Servidor personalizado Express/Fastify | Ecosistema middleware completo | Pierde optimización estática automática |
| Hono en Edge Runtime | Ligero, cadena middleware | No oficialmente soportado por Next.js |
De una aplicación SaaS de Next.js 15 / React 19 en producción (SystemsArchitect.io).
// Ejemplo de producción: Ruta API con composición de seguridad y seguimiento de uso atómico
// Archivo: app/api/generate/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { withCsrfProtection } from '@/lib/csrf';
import { withRateLimit } from '@/lib/rate-limit';
import { prisma } from '@/lib/prisma';
import { getSession } from '@/lib/auth';
export const maxDuration = 60; // Tiempo de espera de función Vercel en segundos
async function handler(request: NextRequest) {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
const { prompt, projectId } = body;
// Seguimiento de uso atómico: deduce crédito antes de la operación
const updated = await prisma.user.updateMany({
where: {
id: session.userId,
credits: { gt: 0 },
},
data: {
credits: { decrement: 1 },
},
});
if (updated.count === 0) {
return NextResponse.json({ error: 'No credits remaining' }, { status: 402 });
}
try {
const result = await generateContent(prompt, projectId);
return NextResponse.json({ result });
} catch (error) {
// Revierte: restaura el crédito en fallo
await prisma.user.update({
where: { id: session.userId },
data: { credits: { increment: 1 } },
});
return NextResponse.json({ error: 'Generation failed' }, { status: 500 });
}
}
// Composición middleware de seguridad: CSRF check envuelve rate limiter envuelve handler
export const POST = withCsrfProtection(withRateLimit(handler));Lo que esto demuestra en producción:
withCsrfProtection(withRateLimit(handler)) compone middleware de seguridad como funciones de orden superior. La verificación CSRF se ejecuta primero. Si pasa, se ejecuta el limitador de velocidad. Si ese pasa, se ejecuta el handler. Este patrón de composición evita cadenas middleware profundamente anidadas.export const maxDuration = 60 establece el tiempo de espera de la función serverless de Vercel. El predeterminado es 10 segundos, que es demasiado corto para la generación de contenido de IA. Esta configuración es específica para despliegues de Vercel.updateMany con una condición where: { credits: { gt: 0 } }. Esta es una operación atómica: el crédito solo se deduce si el usuario tiene créditos restantes. Previene condiciones de carrera donde dos solicitudes simultáneas podrían ambas leer credits: 1 y ambas proceder.updated.count === 0 significa que el usuario no fue encontrado o tenía cero créditos. Este verificación única maneja ambos casos sin una consulta de lectura separada.POST como la función compuesta (no handler directamente) asegura que cada solicitud POST pase a través de ambas capas de seguridad. Las exportaciones nombradas como GET, POST, PUT se asignan directamente a métodos HTTP en gestores de ruta de Next.js.De una aplicación SaaS de Next.js 15 / React 19 en producción (SystemsArchitect.io).
// Ejemplo de producción: Limitación de velocidad segura contra TOCTOU con upsert de Prisma
// Archivo: src/lib/tools/usage-limits.ts
export async function checkAndIncrementUsage(
toolId: string,
userId: string | null,
ipAddress: string,
userTier: UserTier
): Promise<{ allowed: boolean; limit: number; used: number; resetsAt: string }> {
const limit = await getToolUsageLimitAsync(toolId, userTier);
if (limit === -1) {
await incrementToolUsage(toolId, userId, ipAddress);
return { allowed: true, limit: -1, used: 0, resetsAt: new Date(Date.now() + 86400000).toISOString() };
}
const today = new Date();
today.setUTCHours(0, 0, 0, 0);
const tomorrow = new Date(today);
tomorrow.setUTCDate(tomorrow.getUTCDate() + 1);
const sessionId = userId || ipAddress;
// Upsert atómico: incrementa primero, luego verifica
const updated = await prisma.toolUsage.upsert({
where: {
sessionId_ipAddress_toolName: { sessionId, ipAddress, toolName: toolId },
},
create: { toolName: toolId, userId, sessionId, ipAddress, usageCount: 1, lastUsedAt: new Date() },
update: { usageCount: { increment: 1 }, lastUsedAt: new Date() },
});
// Reinicio de límite de día
if (updated.lastUsedAt < today) {
const reset = await prisma.toolUsage.update({
where: { id: updated.id },
data: { usageCount: 1, lastUsedAt: new Date() },
});
return { allowed: true, limit, used: reset.usageCount, resetsAt: tomorrow.toISOString() };
}
// ¿Superó el límite? Revierte el incremento
if (updated.usageCount > limit) {
await prisma.toolUsage.update({
where: { id: updated.id },
data: { usageCount: { decrement: 1 } },
});
return { allowed: false, limit, used: updated.usageCount - 1, resetsAt: tomorrow.toISOString() };
}
return { allowed: true, limit, used: updated.usageCount, resetsAt: tomorrow.toISOString() };
}Lo que esto demuestra en producción:
upsert crea el registro en primer uso o incrementa en usos posteriores. Sin consulta "¿existe esto?" separadasessionId_ipAddress_toolName asegura un contador por usuario por herramienta por díauserId || ipAddress como sessionId maneja tanto usuarios autenticados como anónimoslimit === -1 significa ilimitado (tier admin). Aún rastrea el uso para análisis pero nunca niegaupdated.lastUsedAt < today detecta registros obsoletos de ayer y reinicia a 1{ decrement: 1 } deshace el incremento optimista cuando se excede el límiteGET, POST, PUT, PATCH, DELETE, HEAD y OPTIONS.route.ts dentro del directorio app.GET sin entrada dinámica en tiempo de compilación.searchParams, cookies() o headers() opta por el renderizado dinámico automáticamente.export const dynamic = "force-dynamic" para forzar el comportamiento dinámico explícitamente.NextRequest extiende Request con .nextUrl (URL analizada), .cookies, .geo e .ip.NextRequest para acceder a estas conveniencias específicas de Next.js.Request estándar funciona pero carece de estos ayudantes.type Params = { params: Promise<{ id: string }> };
export async function GET(request: NextRequest, { params }: Params) {
const { id } = await params;
}app/api/ para evitar conflictos con rutas de página.return new NextResponse(null, { status: 204 });NextResponse.json(null, { status: 204 }) ya que envía un cuerpo JSON vacío.new NextResponse(null, { status: 204 }) para una respuesta verdadera sin contenido.request.json() en un bloque try/catch.withCsrfProtection(withRateLimit(handler)) encadena funciones de orden superior.updateMany con where: { credits: { gt: 0 } } verifica y decrementa en una sola consulta.credits: 1 y ambas proceder.updated.count === 0 significa que la deducción falló (sin créditos o usuario no encontrado).import { z } from "zod";
const bodySchema = z.object({
title: z.string().min(1),
content: z.string().min(1),
});
const parsed = bodySchema.safeParse(await request.json());
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.flatten() },
{ status: 400 }
);
}
// parsed.data is fully typedOPTIONS que devuelve encabezados CORS con un estado 204.Access-Control-Allow-Origin, Access-Control-Allow-Methods y Access-Control-Allow-Headers.Revisado por Chris St. John·Última actualización: 19 jul 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥