Route Handlers en el App Router de Next.js
El App Router de Next.js introdujo un nuevo paradigma para construir endpoints de API: los
Route Handlers. A diferencia del antiguo directorio pages/api, los Route Handlers
utilizan las APIs estandar de la Web Request y Response, haciendolos
mas portables, testeables y alineados con los estandares web modernos. Conviven junto a tus
paginas en el directorio app/, siguiendo las mismas convenciones de enrutamiento
basado en archivos.
Un Route Handler se define exportando funciones asincronas nombradas segun los metodos HTTP
desde un archivo route.ts. Cada funcion recibe un objeto Request
estandar y devuelve un Response o NextResponse.
// app/api/hello/route.ts
import { NextResponse } from "next/server";
export async function GET() {
return NextResponse.json({ message: "Hola desde la API" });
}
export async function POST(request: Request) {
const body = await request.json();
return NextResponse.json(
{ received: body },
{ status: 201 }
);
}
export async function PUT(request: Request) {
const body = await request.json();
return NextResponse.json({ updated: body });
}
export async function DELETE(request: Request) {
return NextResponse.json(
{ deleted: true },
{ status: 200 }
);
}
Solo los metodos HTTP que exportes estaran disponibles. Si un cliente envia una solicitud con
un metodo no soportado, Next.js devuelve automaticamente una respuesta
405 Method Not Allowed.
Parseo de solicitudes: Params, SearchParams y Body
Entender como extraer datos de las solicitudes entrantes es fundamental. Los Route Handlers proporcionan tres fuentes principales de datos: parametros de ruta, parametros de busqueda de URL y el cuerpo de la solicitud.
Parametros de ruta
Los segmentos de ruta dinamicos se definen usando corchetes en el nombre de la carpeta y se pasan como segundo argumento al handler:
// app/api/projects/[id]/route.ts
import { NextResponse } from "next/server";
interface RouteParams {
params: Promise<{ id: string }>;
}
export async function GET(request: Request, { params }: RouteParams) {
const { id } = await params;
// Validar el formato del ID
if (!id || typeof id !== "string") {
return NextResponse.json(
{ error: "ID de proyecto invalido" },
{ status: 400 }
);
}
const project = await getProjectById(id);
if (!project) {
return NextResponse.json(
{ error: "Proyecto no encontrado" },
{ status: 404 }
);
}
return NextResponse.json(project);
}Parametros de busqueda
Los parametros de consulta de la URL se extraen de la URL de la solicitud usando la API
estandar URL:
// app/api/projects/route.ts
import { NextResponse } from "next/server";
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const page = parseInt(searchParams.get("page") || "1", 10);
const limit = parseInt(searchParams.get("limit") || "10", 10);
const category = searchParams.get("category");
const sortBy = searchParams.get("sortBy") || "createdAt";
const order = searchParams.get("order") || "desc";
const projects = await getProjects({
page,
limit,
category: category || undefined,
sortBy,
order: order as "asc" | "desc",
});
return NextResponse.json(projects);
}Cuerpo de la solicitud
El cuerpo de la solicitud puede parsearse como JSON, FormData, texto o un blob binario dependiendo del tipo de contenido:
// Cuerpo JSON
export async function POST(request: Request) {
const body = await request.json();
// body tiene tipo 'any' — validar con Zod a continuacion
}
// Cuerpo FormData
export async function POST(request: Request) {
const formData = await request.formData();
const name = formData.get("name") as string;
const file = formData.get("file") as File;
}
// Cuerpo de texto
export async function POST(request: Request) {
const text = await request.text();
}
// Cuerpo binario
export async function POST(request: Request) {
const buffer = await request.arrayBuffer();
}Validacion de entrada con Zod
Nunca confies en la entrada del usuario. Cada ruta de API debe validar su entrada antes de procesarla. Zod es el estandar para la validacion de esquemas con TypeScript — valida en tiempo de ejecucion e infiere tipos TypeScript a partir de la definicion del esquema.
npm install zod// lib/validations/contact.ts
import { z } from "zod";
export const contactSchema = z.object({
name: z
.string()
.min(2, "El nombre debe tener al menos 2 caracteres")
.max(100, "El nombre debe tener menos de 100 caracteres")
.trim(),
email: z
.string()
.email("Direccion de email invalida")
.toLowerCase(),
subject: z
.string()
.min(5, "El asunto debe tener al menos 5 caracteres")
.max(200, "El asunto debe tener menos de 200 caracteres")
.trim(),
message: z
.string()
.min(10, "El mensaje debe tener al menos 10 caracteres")
.max(5000, "El mensaje debe tener menos de 5000 caracteres")
.trim(),
locale: z.enum(["en", "es"]).default("es"),
});
export type ContactInput = z.infer<typeof contactSchema>;// app/api/contact/route.ts
import { NextResponse } from "next/server";
import { contactSchema } from "@/lib/validations/contact";
import { ZodError } from "zod";
export async function POST(request: Request) {
try {
const body = await request.json();
const validatedData = contactSchema.parse(body);
// validatedData tiene el tipo completo ContactInput
await sendEmail({
to: process.env.MAILJET_TO_EMAIL!,
subject: validatedData.subject,
name: validatedData.name,
email: validatedData.email,
message: validatedData.message,
});
return NextResponse.json(
{ success: true, message: "Email enviado correctamente" },
{ status: 200 }
);
} catch (error) {
if (error instanceof ZodError) {
return NextResponse.json(
{
error: "Validacion fallida",
details: error.errors.map((err) => ({
field: err.path.join("."),
message: err.message,
})),
},
{ status: 400 }
);
}
console.error("Error en API de contacto:", error);
return NextResponse.json(
{ error: "Error interno del servidor" },
{ status: 500 }
);
}
}Para validaciones mas complejas, Zod soporta refinamientos, transformaciones y uniones discriminadas:
// Patrones avanzados de Zod
const projectSchema = z.object({
title: z.string().min(3).max(100),
description: z.string().min(10).max(2000),
url: z.string().url().optional(),
tags: z.array(z.string()).min(1).max(10),
budget: z.number().positive().max(1000000),
startDate: z.string().datetime(),
endDate: z.string().datetime().optional(),
}).refine(
(data) => {
if (data.endDate) {
return new Date(data.endDate) > new Date(data.startDate);
}
return true;
},
{
message: "La fecha de fin debe ser posterior a la fecha de inicio",
path: ["endDate"],
}
);Patrones de manejo de errores
El manejo consistente de errores es esencial para una API confiable. Define un formato estandar de respuesta de error y usalo en todas tus rutas. Esto facilita que el codigo frontend maneje los errores de forma predecible.
// lib/api/errors.ts
export class ApiError extends Error {
constructor(
message: string,
public statusCode: number,
public code: string,
public details?: Record<string, unknown>
) {
super(message);
this.name = "ApiError";
}
}
export class NotFoundError extends ApiError {
constructor(resource: string) {
super(
`${resource} no encontrado`,
404,
"NOT_FOUND"
);
}
}
export class ValidationError extends ApiError {
constructor(message: string, details?: Record<string, unknown>) {
super(message, 400, "VALIDATION_ERROR", details);
}
}
export class UnauthorizedError extends ApiError {
constructor(message = "Autenticacion requerida") {
super(message, 401, "UNAUTHORIZED");
}
}
export class ForbiddenError extends ApiError {
constructor(message = "Permisos insuficientes") {
super(message, 403, "FORBIDDEN");
}
}
export class RateLimitError extends ApiError {
constructor(retryAfter: number) {
super(
"Demasiadas solicitudes",
429,
"RATE_LIMIT_EXCEEDED",
{ retryAfter }
);
}
}// lib/api/handler.ts
import { NextResponse } from "next/server";
import { ZodError } from "zod";
import { ApiError } from "./errors";
interface ApiErrorResponse {
error: {
code: string;
message: string;
details?: Record<string, unknown>;
};
}
export function handleApiError(error: unknown): NextResponse<ApiErrorResponse> {
// Errores API conocidos
if (error instanceof ApiError) {
return NextResponse.json(
{
error: {
code: error.code,
message: error.message,
details: error.details,
},
},
{ status: error.statusCode }
);
}
// Errores de validacion Zod
if (error instanceof ZodError) {
return NextResponse.json(
{
error: {
code: "VALIDATION_ERROR",
message: "Datos de entrada invalidos",
details: {
fields: error.errors.map((e) => ({
path: e.path.join("."),
message: e.message,
})),
},
},
},
{ status: 400 }
);
}
// Errores desconocidos
console.error("Error API no manejado:", error);
return NextResponse.json(
{
error: {
code: "INTERNAL_ERROR",
message: "Ha ocurrido un error inesperado",
},
},
{ status: 500 }
);
}Ahora tus route handlers quedan limpios y enfocados en la logica de negocio:
// app/api/projects/[id]/route.ts
import { NextResponse } from "next/server";
import { handleApiError } from "@/lib/api/handler";
import { NotFoundError } from "@/lib/api/errors";
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const project = await getProjectById(id);
if (!project) {
throw new NotFoundError("Proyecto");
}
return NextResponse.json(project);
} catch (error) {
return handleApiError(error);
}
}Middleware de autenticacion con funciones de orden superior
Proteger las rutas de API con autenticacion es una preocupacion transversal que no deberia duplicarse en cada route handler. Usa un patron de funcion de orden superior para envolver las rutas con logica de autenticacion:
// lib/api/withAuth.ts
import { NextResponse } from "next/server";
import { headers } from "next/headers";
import { verifyToken } from "@/lib/auth";
import { UnauthorizedError, ForbiddenError } from "./errors";
import { handleApiError } from "./handler";
interface AuthenticatedUser {
id: string;
email: string;
role: "admin" | "user";
}
type AuthenticatedHandler = (
request: Request,
context: {
params: Promise<Record<string, string>>;
user: AuthenticatedUser;
}
) => Promise<NextResponse>;
export function withAuth(
handler: AuthenticatedHandler,
options?: { roles?: string[] }
) {
return async (
request: Request,
context: { params: Promise<Record<string, string>> }
) => {
try {
const headersList = await headers();
const authorization = headersList.get("authorization");
if (!authorization || !authorization.startsWith("Bearer ")) {
throw new UnauthorizedError("Cabecera de autorizacion ausente o invalida");
}
const token = authorization.slice(7);
const user = await verifyToken(token);
if (!user) {
throw new UnauthorizedError("Token invalido o expirado");
}
// Verificar acceso basado en roles
if (options?.roles && !options.roles.includes(user.role)) {
throw new ForbiddenError("Permisos insuficientes para este recurso");
}
return handler(request, { ...context, user });
} catch (error) {
return handleApiError(error);
}
};
}// app/api/admin/users/route.ts
import { NextResponse } from "next/server";
import { withAuth } from "@/lib/api/withAuth";
export const GET = withAuth(
async (request, { user }) => {
// user tiene tipo y esta garantizado como autenticado
const users = await getAllUsers();
return NextResponse.json(users);
},
{ roles: ["admin"] } // Solo los admins pueden acceder a este endpoint
);
export const POST = withAuth(
async (request, { user }) => {
const body = await request.json();
const newUser = await createUser(body);
return NextResponse.json(newUser, { status: 201 });
},
{ roles: ["admin"] }
);Configuracion de CORS
Si tu API es consumida por clientes externos o dominios diferentes, necesitas configurar Cross-Origin Resource Sharing (CORS). Next.js no habilita CORS por defecto para las rutas de API.
// lib/api/cors.ts
import { NextResponse } from "next/server";
const ALLOWED_ORIGINS = [
"https://raymartin.es",
"https://www.raymartin.es",
process.env.NODE_ENV === "development" && "http://localhost:3000",
].filter(Boolean) as string[];
export function corsHeaders(origin: string | null) {
const headers = new Headers();
if (origin && ALLOWED_ORIGINS.includes(origin)) {
headers.set("Access-Control-Allow-Origin", origin);
}
headers.set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
headers.set("Access-Control-Allow-Headers", "Content-Type, Authorization");
headers.set("Access-Control-Max-Age", "86400");
return headers;
}
export function withCors(handler: (request: Request) => Promise<NextResponse>) {
return async (request: Request) => {
const origin = request.headers.get("origin");
// Manejar solicitudes preflight
if (request.method === "OPTIONS") {
return new NextResponse(null, {
status: 204,
headers: corsHeaders(origin),
});
}
const response = await handler(request);
// Agregar cabeceras CORS a la respuesta
const headers = corsHeaders(origin);
headers.forEach((value, key) => {
response.headers.set(key, value);
});
return response;
};
}// app/api/public/data/route.ts
import { NextResponse } from "next/server";
import { withCors } from "@/lib/api/cors";
export const GET = withCors(async (request: Request) => {
const data = await getPublicData();
return NextResponse.json(data);
});
// Manejar OPTIONS preflight
export const OPTIONS = withCors(async () => {
return new NextResponse(null, { status: 204 });
});Estrategias de rate limiting
El rate limiting protege tu API contra abusos y garantiza un uso justo. Para Next.js desplegado en Vercel, puedes implementar rate limiting con un almacen en memoria para desarrollo y un almacen basado en Redis para produccion.
// lib/api/rateLimit.ts
interface RateLimitEntry {
count: number;
resetAt: number;
}
const rateLimitMap = new Map<string, RateLimitEntry>();
interface RateLimitOptions {
windowMs: number; // Ventana de tiempo en milisegundos
maxRequests: number; // Maximo de solicitudes por ventana
}
export function rateLimit(
identifier: string,
options: RateLimitOptions = { windowMs: 60_000, maxRequests: 10 }
): { success: boolean; remaining: number; resetAt: number } {
const now = Date.now();
const entry = rateLimitMap.get(identifier);
// Limpiar entradas expiradas
if (entry && now > entry.resetAt) {
rateLimitMap.delete(identifier);
}
const current = rateLimitMap.get(identifier);
if (!current) {
const resetAt = now + options.windowMs;
rateLimitMap.set(identifier, { count: 1, resetAt });
return { success: true, remaining: options.maxRequests - 1, resetAt };
}
if (current.count >= options.maxRequests) {
return { success: false, remaining: 0, resetAt: current.resetAt };
}
current.count += 1;
return {
success: true,
remaining: options.maxRequests - current.count,
resetAt: current.resetAt,
};
}// app/api/contact/route.ts
import { NextResponse } from "next/server";
import { rateLimit } from "@/lib/api/rateLimit";
export async function POST(request: Request) {
// Rate limit por direccion IP
const forwarded = request.headers.get("x-forwarded-for");
const ip = forwarded?.split(",")[0]?.trim() || "unknown";
const { success, remaining, resetAt } = rateLimit(ip, {
windowMs: 60_000, // Ventana de 1 minuto
maxRequests: 5, // 5 solicitudes por minuto
});
if (!success) {
return NextResponse.json(
{ error: "Demasiadas solicitudes. Por favor intentalo mas tarde." },
{
status: 429,
headers: {
"Retry-After": String(Math.ceil((resetAt - Date.now()) / 1000)),
"X-RateLimit-Remaining": "0",
},
}
);
}
// Procesar la solicitud normalmente...
const body = await request.json();
const response = NextResponse.json({ success: true });
response.headers.set("X-RateLimit-Remaining", String(remaining));
return response;
}Patrones de paginacion
Hay dos estrategias comunes de paginacion: basada en offset y basada en cursor. Cada una tiene ventajas y desventajas que la hacen adecuada para diferentes casos de uso.
Paginacion basada en offset
La paginacion por offset usa un numero de pagina y un tamano de pagina. Es simple de implementar y permite saltar a cualquier pagina, pero puede ser lenta en conjuntos de datos grandes y sufre problemas de consistencia cuando se insertan o eliminan datos entre paginas.
// app/api/projects/route.ts
import { NextResponse } from "next/server";
import { z } from "zod";
const paginationSchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
});
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const { page, limit } = paginationSchema.parse({
page: searchParams.get("page"),
limit: searchParams.get("limit"),
});
const offset = (page - 1) * limit;
const [projects, total] = await Promise.all([
getProjects({ offset, limit }),
countProjects(),
]);
const totalPages = Math.ceil(total / limit);
return NextResponse.json({
data: projects,
pagination: {
page,
limit,
total,
totalPages,
hasNext: page < totalPages,
hasPrev: page > 1,
},
});
}Paginacion basada en cursor
La paginacion por cursor usa un cursor opaco (tipicamente un ID o timestamp codificado) para obtener el siguiente conjunto de resultados. Es mas eficiente en conjuntos de datos grandes y maneja inserciones y eliminaciones de forma elegante, pero no soporta saltar a paginas arbitrarias.
// app/api/feed/route.ts
import { NextResponse } from "next/server";
import { z } from "zod";
const cursorSchema = z.object({
cursor: z.string().optional(),
limit: z.coerce.number().int().min(1).max(50).default(20),
});
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const { cursor, limit } = cursorSchema.parse({
cursor: searchParams.get("cursor"),
limit: searchParams.get("limit"),
});
const decodedCursor = cursor
? JSON.parse(Buffer.from(cursor, "base64url").toString())
: null;
// Obtener un elemento extra para determinar si hay mas resultados
const items = await getFeedItems({
cursor: decodedCursor,
limit: limit + 1,
});
const hasMore = items.length > limit;
const data = hasMore ? items.slice(0, limit) : items;
const nextCursor = hasMore
? Buffer.from(
JSON.stringify({ id: data[data.length - 1].id })
).toString("base64url")
: null;
return NextResponse.json({
data,
pagination: {
nextCursor,
hasMore,
},
});
}Subida de archivos con FormData
La gestion de subida de archivos en Route Handlers usa la API estandar FormData.
La clave es validar los tipos y tamanos de archivo antes de procesarlos.
// app/api/upload/route.ts
import { NextResponse } from "next/server";
import { writeFile, mkdir } from "fs/promises";
import path from "path";
const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp", "application/pdf"];
const MAX_SIZE = 5 * 1024 * 1024; // 5MB
export async function POST(request: Request) {
try {
const formData = await request.formData();
const file = formData.get("file") as File | null;
if (!file) {
return NextResponse.json(
{ error: "No se proporciono ningun archivo" },
{ status: 400 }
);
}
// Validar tipo de archivo
if (!ALLOWED_TYPES.includes(file.type)) {
return NextResponse.json(
{ error: `El tipo de archivo ${file.type} no esta permitido. Tipos permitidos: ${ALLOWED_TYPES.join(", ")}` },
{ status: 400 }
);
}
// Validar tamano del archivo
if (file.size > MAX_SIZE) {
return NextResponse.json(
{ error: `El tamano del archivo excede el limite de ${MAX_SIZE / 1024 / 1024}MB` },
{ status: 400 }
);
}
// Generar un nombre de archivo unico
const timestamp = Date.now();
const extension = file.name.split(".").pop();
const filename = `${timestamp}-${Math.random().toString(36).slice(2)}.${extension}`;
// Convertir el archivo a un buffer
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);
// Guardar en el directorio de uploads
const uploadsDir = path.join(process.cwd(), "public", "uploads");
await mkdir(uploadsDir, { recursive: true });
await writeFile(path.join(uploadsDir, filename), buffer);
return NextResponse.json(
{
success: true,
file: {
name: filename,
url: `/uploads/${filename}`,
size: file.size,
type: file.type,
},
},
{ status: 201 }
);
} catch (error) {
console.error("Error de subida:", error);
return NextResponse.json(
{ error: "Fallo al subir el archivo" },
{ status: 500 }
);
}
}
En el lado del cliente, envia archivos usando FormData:
"use client";
async function uploadFile(file: File) {
const formData = new FormData();
formData.append("file", file);
const response = await fetch("/api/upload", {
method: "POST",
body: formData,
// NO establecer la cabecera Content-Type — el navegador la establece con el boundary
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error);
}
return response.json();
}Testing de rutas de API
Los Route Handlers son funciones que reciben un Request y devuelven un
Response, lo que hace que sean directos de testear sin levantar un servidor.
Puedes testearlos directamente construyendo objetos Request y verificando el
Response devuelto.
// __tests__/api/contact.test.ts
import { POST } from "@/app/api/contact/route";
describe("POST /api/contact", () => {
it("devuelve 200 para entrada valida", async () => {
const request = new Request("http://localhost:3000/api/contact", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: "Juan Perez",
email: "juan@ejemplo.com",
subject: "Hola desde el test",
message: "Este es un mensaje de prueba con suficientes caracteres.",
}),
});
const response = await POST(request);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.success).toBe(true);
});
it("devuelve 400 para email invalido", async () => {
const request = new Request("http://localhost:3000/api/contact", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: "Juan Perez",
email: "no-es-un-email",
subject: "Asunto de prueba",
message: "Este es un mensaje de prueba con suficientes caracteres.",
}),
});
const response = await POST(request);
const data = await response.json();
expect(response.status).toBe(400);
expect(data.error).toBeDefined();
});
it("devuelve 400 para campos requeridos faltantes", async () => {
const request = new Request("http://localhost:3000/api/contact", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Juan" }),
});
const response = await POST(request);
expect(response.status).toBe(400);
});
});Organizacion de rutas: convenciones RESTful
El enrutamiento basado en archivos del App Router se mapea naturalmente a las convenciones de API RESTful. Organiza tus rutas siguiendo un patron consistente que haga tu API predecible y facil de navegar.
# Estructura de rutas RESTful en el App Router
app/
api/
# Recurso: Proyectos
projects/
route.ts # GET (listar), POST (crear)
[id]/
route.ts # GET (leer), PUT (actualizar), DELETE (eliminar)
publish/
route.ts # POST (accion personalizada)
# Recurso: Usuarios
users/
route.ts # GET (listar), POST (crear)
[id]/
route.ts # GET, PUT, DELETE
projects/
route.ts # GET (anidado: proyectos del usuario)
# Endpoints sin recurso
contact/
route.ts # Solo POST
upload/
route.ts # Solo POST
health/
route.ts # Solo GET (verificacion de salud)Sigue estas convenciones para una API limpia y predecible:
- Usa sustantivos en plural para recursos:
/api/projects, no/api/project. - Usa metodos HTTP para operaciones: GET para leer, POST para crear, PUT para actualizar, DELETE para eliminar.
- Anida recursos relacionados:
/api/users/[id]/projectspara los proyectos de un usuario. - Usa verbos para acciones no-CRUD:
/api/projects/[id]/publishpara publicar un proyecto. - Devuelve codigos de estado apropiados: 200 para exito, 201 para creacion, 204 para eliminacion, 400 para entrada invalida, 401 para no autenticado, 404 para no encontrado, 500 para errores del servidor.
- Usa formas de respuesta consistentes: Siempre incluye un campo
datapara el exito y un campoerrorpara los fallos.
Disenar rutas de API robustas en Next.js requiere pensar mas alla de simplemente manejar solicitudes. Al combinar validacion con Zod, manejo estructurado de errores, middleware de autenticacion, rate limiting y convenciones RESTful, creas una capa de API que es segura, mantenible y agradable de usar — tanto para los desarrolladores que la construyen como para los clientes que la consumen.