Route Handlers: escribe API en Next.js
1. Descripción general
En el App Router de Next.js, no necesitas montar un servidor Express separado para tener una API. Los Route Handlers te permiten escribir endpoints HTTP directamente en la carpeta app, usando las APIs web estándar Request y Response.
Este artículo construye una pequeña API de gestión de pedidos (Order). Aprenderás a definir métodos HTTP, leer query strings y body, manejar segmentos dinámicos, trabajar con headers/cookies, controlar el caching y escribir tests con Vitest. Los ejemplos usan Next.js 15+ con TypeScript.
2. Tu primer Route Handler
Un Route Handler es un archivo llamado route.ts (o route.js) dentro de app. La ruta de la carpeta determina la URL. Cada método HTTP es una función exportada con el nombre en mayúsculas.
En primer lugar, necesitamos un simple “almacén” de datos en memoria:
// lib/orders.ts
export type Order = {
id: string;
customer: string;
total: number;
};
const orders: Order[] = [
{ id: "1", customer: "An", total: 250_000 },
{ id: "2", customer: "Bình", total: 1_200_000 },
];
export const orderStore = {
findAll: () => [...orders],
findById: (id: string) => orders.find((o) => o.id === id),
create: (data: Omit<Order, "id">): Order => {
const order = { id: String(orders.length + 1), ...data };
orders.push(order);
return order;
},
remove: (id: string) => {
const index = orders.findIndex((o) => o.id === id);
if (index === -1) return false;
orders.splice(index, 1);
return true;
},
};
Ahora crea el endpoint GET /api/orders:
// app/api/orders/route.ts
import { orderStore } from "@/lib/orders";
export async function GET() {
return Response.json(orderStore.findAll());
}
Explicación:
- El archivo está en
app/api/orders/route.ts, por lo que la URL es/api/orders. El prefijoapies solo una convención. Next.js no lo requiere. export async function GET()maneja la solicitudGET. Next.js automáticamente devuelve405 Method Not Allowedpara los métodos que no exportas.Response.json(...)es la API web estándar. Serializa los datos y establece el headerContent-Type: application/json.
Ejecuta npm run dev y luego haz una llamada de prueba:
curl http://localhost:3000/api/orders
[{"id":"1","customer":"An","total":250000},{"id":"2","customer":"Bình","total":1200000}]
Nota: En la misma carpeta, no puedes tener tanto
route.tscomopage.tsx. Por ejemplo,app/orders/page.tsxyapp/orders/route.tsentrarían en conflicto. Por eso generalmente se agrupan las APIs bajoapp/api.
3. Leer query strings y body
3.1. Query string
El parámetro del handler es una Request estándar. Para leer el query string, creas una URL desde request.url:
// app/api/orders/route.ts
import { orderStore } from "@/lib/orders";
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const minTotal = Number(searchParams.get("minTotal") ?? 0);
const result = orderStore.findAll().filter((o) => o.total >= minTotal);
return Response.json(result);
}
Llamar a /api/orders?minTotal=500000 devolverá solo el pedido de Bình:
[{"id":"2","customer":"Bình","total":1200000}]
Si lo deseas, puedes declarar el parámetro como NextRequest (de next/server) y usar request.nextUrl.searchParams. Aquí uso Request estándar porque es más fácil de crear en tests.
3.2. Body JSON y validación
No confíes en los datos que envía el cliente. Usa zod para validar el body. Instala la librería:
npm install zod
Añade el handler POST en el mismo archivo:
// app/api/orders/route.ts (continuación)
import { z } from "zod";
const CreateOrderSchema = z.object({
customer: z.string().min(1),
total: z.number().positive(),
});
export async function POST(request: Request) {
let body: unknown;
try {
body = await request.json();
} catch {
return Response.json({ error: "Body no es un JSON válido" }, { status: 400 });
}
const parsed = CreateOrderSchema.safeParse(body);
if (!parsed.success) {
return Response.json(
{ error: "Datos inválidos", issues: parsed.error.issues },
{ status: 422 },
);
}
const order = orderStore.create(parsed.data);
return Response.json(order, {
status: 201,
headers: { Location: `/api/orders/${order.id}` },
});
}
Explicación:
request.json()lanzará una excepción si el body está vacío o tiene una sintaxis incorrecta. Por eso lo envolvemos entry/catchy devolvemos400.safeParseno lanza excepción. Devuelve{ success, data }o{ success, error }. Los datos con tipo incorrecto recibirán422.- El segundo parámetro de
Response.jsonte permite establecerstatusyheaders. Aquí devolvemos201 Createdcon el headerLocation.
curl -i -X POST http://localhost:3000/api/orders \
-H "Content-Type: application/json" \
-d '{"customer":"Chi","total":99000}'
HTTP/1.1 201 Created
location: /api/orders/3
content-type: application/json
{"id":"3","customer":"Chi","total":99000}
Para formularios HTML, usas await request.formData(). Para datos sin procesar, usas await request.text(). Ten en cuenta que el body solo se puede leer una sola vez.
4. Segmentos dinámicos
Para tener GET /api/orders/:id, creas una carpeta con un nombre entre corchetes:
// app/api/orders/[id]/route.ts
import { orderStore } from "@/lib/orders";
type Context = { params: Promise<{ id: string }> };
export async function GET(_request: Request, { params }: Context) {
const { id } = await params;
const order = orderStore.findById(id);
if (!order) {
return Response.json({ error: `No se encontró el pedido ${id}` }, { status: 404 });
}
return Response.json(order);
}
export async function DELETE(_request: Request, { params }: Context) {
const { id } = await params;
const removed = orderStore.remove(id);
return new Response(null, { status: removed ? 204 : 404 });
}
Explicación:
- El segundo parámetro contiene
params. A partir de Next.js 15,paramses una Promise, por lo que debes hacerawaiten ella. DELETEdevuelve204 No Contentcon bodynull. No debes usarResponse.jsonpara esto, porque204no debe tener body.- Los valores en
paramssiempre sonstring. Si el ID es un número, debes convertirlo y validarlo tú mismo.
También puedes usar catch-all [...slug] (recibe string[]) o catch-all opcional [[...slug]].
Nota: El código antiguo que escribía
params.idsinawaitcausará advertencias o errores en Next.js 15+. Al actualizar desde Next.js 14, este es el lugar más común donde el código se rompe.
5. Headers, cookies y redirección
Puedes leer headers directamente desde request.headers. Además, Next.js proporciona las funciones cookies() y headers() de next/headers. Ambas son funciones async.
// app/api/me/route.ts
import { cookies, headers } from "next/headers";
export async function GET() {
const cookieStore = await cookies();
const headerList = await headers();
const session = cookieStore.get("session")?.value;
if (!session) {
return Response.json({ error: "No estás conectado" }, { status: 401 });
}
return Response.json({
session,
userAgent: headerList.get("user-agent"),
});
}
export async function DELETE() {
const cookieStore = await cookies();
cookieStore.delete("session");
return new Response(null, { status: 204 });
}
cookieStore.get(...)se usa para leer cookies.setydeletesolo funcionan en Route Handlers o Server Actions, no en Server Components.- Usar
cookies()oheaders()hará que la ruta sea dinámica, es decir, se renderiza en cada solicitud.
Si necesitas redirigir, usa Response.redirect(new URL("/login", request.url), 307) o la función redirect() de next/navigation.
Para CORS, estableces los headers tú mismo y exportas un OPTIONS adicional para el preflight:
// app/api/public/route.ts
const corsHeaders = {
"Access-Control-Allow-Origin": "https://shop.example.com",
"Access-Control-Allow-Methods": "GET, OPTIONS",
};
export async function OPTIONS() {
return new Response(null, { status: 204, headers: corsHeaders });
}
export async function GET() {
return Response.json({ status: "ok" }, { headers: corsHeaders });
}
6. Caching
Esta es la parte que más confunde. A partir de Next.js 15, los handlers GET ya no se cachean por defecto. Cada solicitud ejecuta la función nuevamente. Otros métodos como POST, PUT, DELETE nunca se cachean.
Si los datos cambian poco y quieres compilar como contenido estático, usa route segment config:
// app/api/categories/route.ts
export const dynamic = "force-static";
export const revalidate = 3600; // revalidar máximo cada hora
export async function GET() {
const categories = ["Electrónica", "Libros", "Moda"];
return Response.json({ categories, generatedAt: new Date().toISOString() });
}
dynamic = "force-static"fuerza que la ruta se renderice durante la compilación. El resultado se sirve después como archivo estático.revalidate = 3600permite que Next.js regenere la respuesta máximo cada hora (ISR).- Durante
next dev, la ruta aún se ejecuta en cada solicitud. Para vergeneratedAtsin cambios, necesitasnext build && next start.
Nota: Si una ruta
force-staticleecookies(),headers()o query string, esos valores estarán vacíos en la compilación. No uses static para endpoints que dependan del usuario.
7. Testear Route Handlers con Vitest
Un Route Handler es solo una función async que recibe Request y devuelve Response, así que puedes llamarla directamente sin necesidad de iniciar un servidor. Instala Vitest:
npm install -D vitest vite-tsconfig-paths
// vitest.config.ts
import { defineConfig } from "vitest/config";
import tsconfigPaths from "vite-tsconfig-paths";
export default defineConfig({
plugins: [tsconfigPaths()],
test: { environment: "node" },
});
El plugin vite-tsconfig-paths ayuda a que el alias @/lib/... funcione en los tests. Ahora escribe los tests:
// app/api/orders/route.test.ts
import { describe, it, expect } from "vitest";
import { GET, POST } from "./route";
function jsonRequest(body: unknown) {
return new Request("http://localhost/api/orders", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}
describe("/api/orders", () => {
it("filtra pedidos por minTotal", async () => {
const res = await GET(new Request("http://localhost/api/orders?minTotal=500000"));
const data = await res.json();
expect(res.status).toBe(200);
expect(data.every((o: { total: number }) => o.total >= 500_000)).toBe(true);
});
it("crea un pedido válido y devuelve 201", async () => {
const res = await POST(jsonRequest({ customer: "Dũng", total: 150_000 }));
expect(res.status).toBe(201);
expect(res.headers.get("Location")).toMatch(/^\/api\/orders\/\d+$/);
expect(await res.json()).toMatchObject({ customer: "Dũng", total: 150_000 });
});
it("rechaza total negativo con 422", async () => {
const res = await POST(jsonRequest({ customer: "Dũng", total: -1 }));
expect(res.status).toBe(422);
});
});
npx vitest run
✓ app/api/orders/route.test.ts (3 tests)
Test Files 1 passed (1)
Tests 3 passed (3)
Si el handler usa cookies() o headers() de next/headers, llamarlo directamente lanzará una excepción porque falta el contexto de solicitud de Next.js. En ese caso tienes dos opciones: hacer mock del módulo con vi.mock("next/headers", ...), o leer los valores de request.headers para que el código sea más fácil de probar.
8. Errores comunes y cuándo no usar Route Handlers
Algunos errores comunes:
- Fetch a un Route Handler desde un Server Component. Llamar a
fetch("/api/orders")en un Server Component crea un ciclo HTTP innecesario. Durante la compilación, esta llamada fallaría porque el servidor aún no está en ejecución. En su lugar, llama directamente aorderStore.findAll()o a tu capa de acceso a datos. - Usar Route Handler para mutaciones desde formularios internos. Si solo la UI de tu propia app lo llama, los Server Actions son más limpios. Tienen seguridad de tipo end-to-end e integración incorporada con
revalidatePath. - Guardar estado en variables de módulo, como
orderStorearriba, cuando implementas en serverless. Cada instancia tiene su propia memoria y puede reiniciarse en cualquier momento. El ejemplo en este artículo es solo para ilustrar. En la práctica, necesitas usar una base de datos. - Olvidar que el body solo se puede leer una vez. Si necesitas leerlo dos veces, usa
request.clone(). - Devolver errores sin estructura. Una excepción no atrapada se convertirá en
500con una página de error HTML. Siempre devuelve JSON constatusexplícito.
Los Route Handlers son apropiados cuando necesitas:
- Un endpoint para clientes externos, como una app móvil o socios.
- Un webhook que reciba datos, por ejemplo de Stripe o GitHub.
- Un endpoint que devuelva archivos, RSS o datos en streaming.
- Un endpoint que el código del cliente llame con
fetch.
9. Conclusión
Los Route Handlers convierten cada archivo route.ts en un endpoint HTTP, usando los Request/Response web estándar. En este artículo, aprendiste a:
- Definir métodos
GET,POST,DELETE. - Leer query strings y body, validar body con
zod. - Manejar segmentos dinámicos con
paramscomo Promise. - Trabajar con headers, cookies y CORS.
- Habilitar caché estático cuando es necesario.
- Testear handlers directamente con Vitest sin necesidad de un servidor.
El principio clave a recordar: usa Route Handlers para APIs públicas o webhooks. Para datos internos, consulta directamente en Server Components. Para mutaciones de formularios de tu propia app, usa Server Actions.