30 reglas de API para Next.js
Reglas para construir APIs con Route Handlers y Server Actions de Next.js. Cubre diseño, seguridad, validación, manejo de errores y preocupaciones operacionales.
Diseño y estructura (Reglas 1-10)
1. Usa Route Handlers para consumidores externos, Server Actions para formularios internos. Route Handlers (GET, POST en route.ts) sirven a clientes externos, webhooks e integraciones de terceros. Server Actions sirven a tu propia UI.
| Caso de uso | Enfoque |
|---|---|
| Envío de formulario desde tu UI | Server Action |
| Webhook de Stripe/GitHub | Route Handler |
| REST API para aplicación móvil | Route Handler |
| Mutación de clic de botón | Server Action |
| API pública habilitada para CORS | Route Handler |
2. Organiza Route Handlers por recurso. Sigue convenciones RESTful en tu estructura de archivos.
app/
api/
users/
route.ts # GET (lista), POST (crear)
[id]/
route.ts # GET (detalle), PATCH (actualizar), DELETE
posts/
route.ts
[slug]/
route.ts
3. Usa métodos HTTP apropiados. GET para lecturas, POST para creaciones, PATCH para actualizaciones parciales, PUT para reemplazos completos, DELETE para eliminaciones. Nunca uses GET para mutaciones.
4. Devuelve formas de respuesta consistentes. Cada endpoint debe devolver la misma estructura para éxito y error.
// Éxito
return NextResponse.json({ data: user }, { status: 200 });
// Error
return NextResponse.json(
{ error: { code: "NOT_FOUND", message: "Usuario no encontrado" } },
{ status: 404 }
);
// Lista con paginación
return NextResponse.json({
data: users,
pagination: { page, pageSize, total },
});5. Usa códigos de estado HTTP apropiados.
| Código | Cuándo |
|---|---|
| 200 | Éxito (GET, PATCH, DELETE) |
| 201 | Creado (POST) |
| 204 | Sin contenido (DELETE sin body) |
| 400 | Solicitud incorrecta (validación falló) |
| 401 | No autorizado (sin auth) |
| 403 | Prohibido (auth presente, permisos insuficientes) |
| 404 | No encontrado |
| 409 | Conflicto (recurso duplicado) |
| 422 | Entidad no procesable (formato válido, datos inválidos) |
| 429 | Límite de velocidad alcanzado |
| 500 | Error interno del servidor |
6. Versiona tu API si tiene consumidores externos. Usa versionamiento basado en ruta para APIs externas.
app/api/v1/users/route.ts
app/api/v2/users/route.ts
7. Mantén Route Handlers delgados. Route Handlers deben analizar entrada, llamar una función de servicio, y devolver una respuesta. La lógica de negocio pertenece a lib/ o services/.
// app/api/users/route.ts
import { createUser } from "@/services/users";
export async function POST(request: NextRequest) {
const body = await request.json();
const result = await createUser(body); // La lógica vive en otro lugar
return NextResponse.json({ data: result }, { status: 201 });
}8. Usa helpers de solicitud y respuesta tipificados. Crea funciones utilitarias para patrones comunes.
// lib/api.ts
export function success<T>(data: T, status = 200) {
return NextResponse.json({ data }, { status });
}
export function error(code: string, message: string, status: number) {
return NextResponse.json({ error: { code, message } }, { status });
}
// Uso
return success(user, 201);
return error("NOT_FOUND", "Usuario no encontrado", 404);9. Acepta y valida parámetros de query para endpoints GET. Usa searchParams de la URL y valida con Zod.
import { z } from "zod";
const querySchema = z.object({
page: z.coerce.number().min(1).default(1),
pageSize: z.coerce.number().min(1).max(100).default(20),
search: z.string().optional(),
sort: z.enum(["name", "createdAt", "updatedAt"]).default("createdAt"),
});
export async function GET(request: NextRequest) {
const params = Object.fromEntries(request.nextUrl.searchParams);
const parsed = querySchema.safeParse(params);
if (!parsed.success) {
return error("VALIDATION_ERROR", "Parámetros inválidos", 400);
}
const { page, pageSize, search, sort } = parsed.data;
// ... obtener datos
}10. Valida bodies de solicitud con Zod para POST/PATCH/PUT. Analiza el body y devuelve errores de validación estructurados.
const createUserSchema = z.object({
email: z.string().email(),
name: z.string().min(2).max(100),
role: z.enum(["user", "admin"]).default("user"),
});
export async function POST(request: NextRequest) {
const body = await request.json();
const parsed = createUserSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: { code: "VALIDATION_ERROR", details: parsed.error.flatten() } },
{ status: 400 }
);
}
const user = await db.user.create({ data: parsed.data });
return NextResponse.json({ data: user }, { status: 201 });
}Seguridad (Reglas 11-18)
11. Autentica cada endpoint que no sea público. Verifica la sesión o API key en la parte superior de cada Route Handler y Server Action.
export async function GET(request: NextRequest) {
const session = await auth();
if (!session) {
return error("UNAUTHORIZED", "Autenticación requerida", 401);
}
// ... proceder
}12. Autoriza según la propiedad del recurso. Autenticación prueba identidad. Autorización prueba acceso. Siempre verifica que el usuario posea o tenga permiso para acceder al recurso.
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth();
if (!session) return error("UNAUTHORIZED", "Auth requerida", 401);
const { id } = await params;
const post = await db.post.findUnique({ where: { id } });
if (!post) return error("NOT_FOUND", "Post no encontrado", 404);
if (post.authorId !== session.user.id && session.user.role !== "admin") {
return error("FORBIDDEN", "No autorizado", 403);
}
await db.post.delete({ where: { id } });
return new NextResponse(null, { status: 204 });
}13. Valida firmas de webhooks. Para Stripe, GitHub y otros proveedores de webhooks, siempre verifica la firma antes de procesar.
export async function POST(request: NextRequest) {
const body = await request.text();
const signature = request.headers.get("stripe-signature")!;
try {
const event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
// Procesar evento
} catch {
return error("INVALID_SIGNATURE", "Firma de webhook inválida", 400);
}
}14. Limita la velocidad de tus endpoints de API. Protégete contra abusos con limitación de velocidad. Usa stores en memoria para desarrollo, Redis para producción.
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(10, "10 s"),
});
export async function POST(request: NextRequest) {
const ip = request.headers.get("x-forwarded-for") ?? "anonymous";
const { success } = await ratelimit.limit(ip);
if (!success) {
return error("RATE_LIMITED", "Demasiadas solicitudes", 429);
}
// ... proceder
}15. Nunca expongas detalles de errores internos. Registra el error completo del lado del servidor. Devuelve un mensaje genérico al cliente.
try {
const result = await riskyOperation();
return success(result);
} catch (err) {
console.error("Error interno:", err); // Error completo en logs
return error("INTERNAL_ERROR", "Algo salió mal", 500); // Genérico para cliente
}16. Usa headers CORS solo cuando sea necesario. Solo agrega CORS para endpoints consumidos por frontends externos. CORS mal configurado es un riesgo de seguridad.
export async function OPTIONS() {
return new NextResponse(null, {
headers: {
"Access-Control-Allow-Origin": "https://trusted-domain.com",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
},
});
}17. Desinfecta entrada del usuario antes de consultas de base de datos. Incluso con ORMs como Prisma (que parametrizan consultas), valida y desinfecta entrada. Nunca interpoles entrada del usuario en SQL crudo.
18. Establece headers de cache apropiados. Endpoints GET públicos deben especificar caching. Endpoints privados deben prevenir caching.
// Público, cacheable
return NextResponse.json({ data }, {
headers: { "Cache-Control": "public, max-age=60, s-maxage=300" },
});
// Privado, sin cache
return NextResponse.json({ data }, {
headers: { "Cache-Control": "private, no-cache, no-store" },
});Manejo de errores y confiabilidad (Reglas 19-24)
19. Maneja todos los casos de error explícitamente. Verifica no encontrado, no autorizado, errores de validación y estados de conflicto. No confíes en manejo de errores catch-all para casos esperados.
20. Usa try/catch en cada Route Handler. Envuelve el cuerpo completo del handler para prevenir excepciones no manejadas que causen crashes del proceso.
export async function GET(request: NextRequest) {
try {
const session = await auth();
if (!session) return error("UNAUTHORIZED", "Auth requerida", 401);
const data = await fetchData();
return success(data);
} catch (err) {
console.error("GET /api/data falló:", err);
return error("INTERNAL_ERROR", "Algo salió mal", 500);
}
}21. Haz handlers de webhooks idempotentes. Los webhooks pueden entregarse múltiples veces. Almacena el ID del evento y salta duplicados.
export async function POST(request: NextRequest) {
const event = await verifyWebhook(request);
// Verifica si ya fue procesado
const existing = await db.webhookEvent.findUnique({
where: { eventId: event.id },
});
if (existing) return success({ received: true });
// Procesa y registra
await db.$transaction([
processEvent(event),
db.webhookEvent.create({ data: { eventId: event.id } }),
]);
return success({ received: true });
}22. Devuelve temprano para estados inválidos. Verifica precondiciones en la parte superior. Cada verificación devuelve inmediatamente si es inválida. El camino feliz es el código que llega al final.
23. Registra datos estructurados, no strings. Usa logging estructurado para filtrado más fácil y alertas en producción.
console.error({
event: "api.users.create.failed",
userId: session.user.id,
error: err.message,
stack: err.stack,
timestamp: new Date().toISOString(),
});24. Establece timeouts para llamadas externas de API. Usa AbortController con un timeout para prevenir requests colgadas.
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
try {
const res = await fetch(externalUrl, { signal: controller.signal });
return success(await res.json());
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") {
return error("TIMEOUT", "El servicio externo se agotó el tiempo", 504);
}
throw err;
} finally {
clearTimeout(timeout);
}Performance y operaciones (Reglas 25-30)
25. Usa streaming para respuestas grandes. Devuelve ReadableStream para exportaciones CSV, arrays JSON grandes, o datos en tiempo real.
26. Pagina endpoints de lista. Nunca devuelvas listas sin límites. Por defecto usa tamaños de página razonables (20-50) con un tope máximo (100).
27. Usa agrupación de conexiones de base de datos. Crea un cliente Prisma singleton o pool de conexiones. No crees una nueva conexión por request.
// lib/db.ts
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
export const db = globalForPrisma.prisma || new PrismaClient();
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = db;28. Usa runtime edge para endpoints sensibles a latencia. Verificaciones de auth simples, redirects y transformaciones ligeras se benefician del runtime edge.
export const runtime = "edge";
export async function GET(request: NextRequest) {
// Se ejecuta en el edge, más cerca del usuario
}29. Monitorea tiempos de respuesta y tasas de error de API. Rastrea latencias P50, P95 y P99. Alerta en picos de tasa de error. Usa Vercel Analytics, Datadog o similar.
30. Documenta tu API. Mantén una especificación OpenAPI o como mínimo un README con endpoints, ejemplos de solicitud/respuesta y requisitos de autenticación. Tu yo futuro y tu equipo te lo agradecerán.
Preguntas frecuentes
¿Cuándo debes usar un Route Handler vs. un Server Action?
- Route Handler: consumidores externos (apps móviles, integraciones de terceros), webhooks, APIs públicas habilitadas para CORS
- Server Action: envíos de formularios internos y mutaciones de UI desde tu propia app Next.js
¿Qué forma de respuesta debe devolver cada endpoint de API?
// Éxito
{ data: T }
// Error
{ error: { code: string, message: string } }
// Lista
{ data: T[], pagination: { page, pageSize, total } }La consistencia hace el análisis del lado del cliente predecible.
¿Por qué debes mantener Route Handlers delgados?
- Route Handlers deben solo analizar entrada, llamar una función de servicio, y devolver una respuesta
- La lógica de negocio pertenece a
lib/oservices/para testabilidad y reutilización - Los handlers delgados son más fáciles de probar, depurar e intercambiar entre Route Handlers y Server Actions
¿Cómo validas parámetros de query con Zod en un handler GET?
const querySchema = z.object({
page: z.coerce.number().min(1).default(1),
pageSize: z.coerce.number().min(1).max(100).default(20),
});
export async function GET(request: NextRequest) {
const params = Object.fromEntries(request.nextUrl.searchParams);
const parsed = querySchema.safeParse(params);
if (!parsed.success) return error("VALIDATION_ERROR", "Parámetros inválidos", 400);
}Trampa: ¿Qué sucede si olvidas verificar firmas de webhooks?
- Cualquier atacante puede enviar payloads falsos de webhook a tu endpoint
- Sin verificación de firma, puedes procesar eventos fraudulentos (p. ej., confirmaciones de pago falsas de Stripe)
- Siempre verifica usando la clave secreta del proveedor y el header de firma de solicitud
¿Cómo haces handlers de webhooks idempotentes?
- Almacena el ID del evento en tu base de datos cuando proceses un webhook
- En cada solicitud, verifica si el ID del evento ya existe
- Salta el procesamiento si lo hace -- los webhooks pueden entregarse múltiples veces
¿Cómo debes tipar un Route Handler con parámetros dinámicos en TypeScript?
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
// ...
return new NextResponse(null, { status: 204 });
}¿Cómo creas helpers de respuesta tipificados en TypeScript?
export function success<T>(data: T, status = 200) {
return NextResponse.json({ data }, { status });
}
export function error(code: string, message: string, status: number) {
return NextResponse.json({ error: { code, message } }, { status });
}¿Cuál es la diferencia entre códigos de estado 401 y 403?
- 401 No autorizado: no se proporcionó autenticación o la sesión es inválida
- 403 Prohibido: autenticación presente pero el usuario carece de permiso para este recurso
- Verifica auth primero (401), luego verifica permisos (403)
Trampa: ¿Por qué nunca debes devolver detalles de errores internos al cliente?
- Stack traces y mensajes de error pueden revelar rutas de archivo, versiones de biblioteca y esquemas de base de datos
- Los atacantes usan estos detalles para encontrar vulnerabilidades
- Registra el error completo del lado del servidor; devuelve un genérico
"Algo salió mal"al cliente
¿Cómo estableces un timeout para llamadas de API externas en un Route Handler?
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
try {
const res = await fetch(url, { signal: controller.signal });
return success(await res.json());
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") {
return error("TIMEOUT", "El servicio externo se agotó el tiempo", 504);
}
throw err;
} finally {
clearTimeout(timeout);
}¿Por qué es importante la agrupación de conexiones de base de datos en entornos serverless/Next.js?
- Cada solicitud podría crear una nueva conexión de base de datos sin agrupación
- Esto agota rápidamente los límites de conexión de base de datos bajo carga
- Usa un patrón singleton (p. ej., cliente Prisma global) para reutilizar conexiones entre solicitudes
Referencia rápida
| Categoría | Regla clave |
|---|---|
| Diseño | Formas de respuesta consistentes, métodos y códigos de estado HTTP apropiados |
| Seguridad | Auth en cada endpoint, valida toda entrada, limita velocidad, verifica webhooks |
| Errores | Try/catch todo, devuelve temprano, registra datos estructurados |
| Performance | Pagina, agrupa conexiones, transmite respuestas grandes |
| Operaciones | Monitorea latencia, haz webhooks idempotentes, documenta todo |