Route Handlers Type-Safe en Next.js 16: El Error que el 90% Commite en Producción
Cómo construir Route Handlers type-safe en Next.js 16 con Zod, middleware en edge, y validación runtime que realmente funciona en producción.
El 90% de los Desarrolladores Construye Route Handlers Sin Validación — y Les Explota en Producción
Cada vez que desplegáis un Route Handler de Next.js, TypeScript os susurra una mentira tranquilizadora: "Todo tipo correctamente."
Mentira.
TypeScript verifica tipos en compilación. Los payloads llegan en runtime. Cuando un cliente envía `{"age": "treinta"}` en lugar de `{"age": 30}`, TypeScript no puede hacer nada. Vuestro "endpoint type-safe" acaba de aceptar datos inválidos y devolver un error críptico que nadie sabe cómo parsear.
*El problema real no es que falten tipos. Es que la validación de runtime es la pieza que todos omiten.*
Este artículo os muestra cómo construir Route Handlers que realmente garantizan type safety: validación con Zod, envoltorios genéricos, middleware de autenticación en edge, y contratos de respuesta tipados. Todo con Next.js 16 App Router.
La Trampa del Caching en GET Route Handlers
Hay un detalle que la documentación de Next.js menciona de pasada y que destruye APIs en producción:
GET Route Handlers que retornan `Response.json()` se cachean por defecto en producción.
```typescript
// ❌ Esto se cachea automáticamente en producción
// El cliente recibe datos obsoletos durante horas
export async function GET() {
const data = await db.query('SELECT * FROM products');
return Response.json(data);
}
```
```typescript
// ✅ Esto fuerza evaluación dinámica en cada request
export const dynamic = 'force-dynamic';
export async function GET() {
const data = await db.query('SELECT * FROM products');
return NextResponse.json(data);
}
```
La diferencia entre `Response.json()` y `NextResponse.json()` no es obvia. Mientras `Response` sigue el estándar web y se cachea, `NextResponse` mantiene comportamiento dinámico por defecto. Pero la confusión aparece cuando accedéis a `request.nextUrl.searchParams` — eso también desactiva el cacheo implícitamente.
Teams descubren esto cuando sus endpoints "/api/products?filter=new" devuelven el mismo resultado durante horas. La solución trivial (`export const dynamic = 'force-dynamic'`) requiere saber que existe el problema.
El Patrón de Validación Jerárquica en 3 Capas
La mayoría de developers añaden Zod directamente en el handler. Esto funciona, pero no escala. Cuando tienes 20 endpoints, terminas copiando código de validación en cada archivo.
*El patrón que realmente escala: validación jerárquica en 3 capas.*
Capa 1 — Schemas compartidos en `/lib/schemas`
```
lib/
schemas/
user.schema.ts
product.schema.ts
order.schema.ts
```
```typescript
// lib/schemas/user.schema.ts
import { z } from 'zod';
export const createUserSchema = z.object({
name: z.string().min(2).max(100),
email: z.string().email(),
age: z.number().int().positive().optional(),
});
export type CreateUserInput = z.infer<typeof createUserSchema>;
```
Capa 2 — Middleware de validación en el borde
El middleware de Next.js ejecuta antes que cualquier Route Handler. Aquí es donde validáis tokens JWT, rate limiting, y headers de autenticación.
```typescript
// middleware.ts (en la raíz del proyecto)
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
// Validar token de autenticación
const authHeader = request.headers.get('authorization');
if (!authHeader?.startsWith('Bearer ')) {
return NextResponse.json(
{ error: 'Unauthorized', code: 'AUTH_REQUIRED' },
{ status: 401 }
);
}
// Verificar formato básico del token
const token = authHeader.split(' ')[1];
if (token.length < 32) {
return NextResponse.json(
{ error: 'Invalid token format', code: 'INVALID_TOKEN' },
{ status: 401 }
);
}
// Continuar al Route Handler
return NextResponse.next();
}
export const config = {
matcher: ['/api/:path*'],
};
```
Capa 3 — Handler con validación runtime
```typescript
// app/api/users/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { createUserSchema } from '@/lib/schemas/user.schema';
export async function POST(request: NextRequest) {
try {
const body = await request.json();
// Validación runtime — TypeScript no puede hacer esto
const result = createUserSchema.safeParse(body);
if (!result.success) {
return NextResponse.json(
{
error: 'Validation failed',
details: result.error.flatten().fieldErrors,
},
{ status: 400 }
);
}
// Ahora tienes type safety real en runtime
const validatedData = result.data; // CreateUserInput
// Tu lógica de negocio aquí
const user = await db.users.create(validatedData);
return NextResponse.json(user, { status: 201 });
} catch (error) {
console.error('User creation failed:', error);
return NextResponse.json(
{ error: 'Internal server error', code: 'INTERNAL_ERROR' },
{ status: 500 }
);
}
}
```
El Envoltorio Genérico que Elimina Boilerplate
Repetir try/catch, validación, y envelopes de respuesta en cada endpoint es un desastre para mantenibilidad. Aquí entra el `apiHandler`.
```typescript
// lib/api-handler.ts
import { NextRequest, NextResponse } from 'next/server';
import { ZodSchema } from 'zod';
type Handler<T> = (
data: T,
request: NextRequest
) => Promise<NextResponse | Response>;
interface ApiHandlerOptions<T> {
schema: ZodSchema<T>;
handler: Handler<T>;
}
export function createApiHandler<T>({
schema,
handler,
}: ApiHandlerOptions<T>) {
return async (request: NextRequest) => {
try {
const body = await request.json();
const validation = schema.safeParse(body);
if (!validation.success) {
return NextResponse.json(
{
error: 'Validation failed',
code: 'VALIDATION_ERROR',
details: validation.error.flatten().fieldErrors,
},
{ status: 400 }
);
}
return await handler(validation.data, request);
} catch (error) {
console.error('Handler error:', error);
return NextResponse.json(
{
error: 'Internal server error',
code: 'INTERNAL_ERROR',
},
{ status: 500 }
);
}
};
}
```
Uso del envoltorio:
```typescript
// app/api/products/route.ts
import { NextRequest } from 'next/server';
import { createApiHandler } from '@/lib/api-handler';
import { createProductSchema } from '@/lib/schemas/product.schema';
const POST = createApiHandler({
schema: createProductSchema,
handler: async (data, request) => {
const product = await db.products.create(data);
return Response.json(product, { status: 201 });
},
});
export { POST };
```
Contrato de Respuesta Tipado
El 80% de los devs devuelven `{ data }` en éxito y `{ error: string }` en fallo. Pero cuando un endpoint devuelve `{ message: 'Not found' }` y otro `{ error: '404' }`, el front-end necesita switch statements para cada caso.
```typescript
// lib/response.ts
interface SuccessResponse<T> {
data: T;
meta?: {
total?: number;
page?: number;
[key: string]: unknown;
};
}
interface ErrorResponse {
error: string;
code: string;
details?: Record<string, string[]>;
}
export function createSuccess<T>(data: T, meta?: SuccessResponse<T>['meta']) {
return NextResponse.json({ data, meta } as SuccessResponse<T>, {
status: 200,
});
}
export function createError(code: string, message: string, status = 400) {
return NextResponse.json(
{ error: message, code } as ErrorResponse,
{ status }
);
}
```
El cliente consume un único contrato:
```typescript
// Consumir la respuesta en el front-end
const response = await fetch('/api/products', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const result = await response.json();
// result.data siempre existe si response.ok
// result.error siempre existe si !response.ok
```
Pages Router vs App Router: La Diferencia Real
Si migráis desde Pages Router, la diferencia no es solo sintaxis. Es comportamiento.
```
GET /api/users (Pages Router):
Siempre dinámico
Cada request ejecuta el handler
Sin cacheo automático
GET /api/users (App Router):
Se cachea por defecto en producción
Necesita export dynamic = 'force-dynamic' para comportamiento idéntico
```
La nueva API de streaming y Server-Sent Events (SSE) en App Router permite respuestas que fluyen gradualmente. Especialmente útil para integración con modelos de lenguaje:
```typescript
// app/api/chat/route.ts
export async function POST(request: NextRequest) {
const { prompt } = await request.json();
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
// Streaming parcial al cliente
controller.enqueue(encoder.encode('Procesando...\n'));
const result = await llm.generate(prompt);
controller.enqueue(encoder.encode(`Respuesta: ${result}`));
controller.close();
},
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
},
});
}
```
Nota importante: los endpoints de streaming no pueden usar el mismo middleware pipeline que endpoints JSON tradicionales. Timeout limits también varían entre plataformas serverless — Vercel tiene límites diferentes a Railway o AWS Lambda.
Framework: El Patrón de Validación Jerárquica para Route Handlers
Aquí está el sistema completo que os recomiendo implementar:
Paso 1: Crear `/lib/schemas/` con Zod
Centralizad vuestras definiciones de schema. Cada dominio (users, products, orders) tiene su archivo. Los types se inferen automáticamente con `z.infer<typeof schema>`.
Paso 2: Configurar `middleware.ts` para auth y headers
Validación de JWT, rate limiting por IP, stripping de headers inesperados. Todo antes de que el handler se ejecute. Fail fast en el edge, no en serverless.
Paso 3: Crear `createApiHandler` en `/lib/`
El envoltorio genérico que ejecuta: validación → handler → error boundary → response envelope. Cada Route Handler usa este envoltorio.
Paso 4: Exportar `dynamic = 'force-dynamic'` en GET
Cada GET handler que sirva datos dinámicos necesita esta línea. Olvidarla significa servir datos obsoletos en producción.
Paso 5: Compartir schemas con el front-end
Importad los mismos schemas en vuestras funciones de fetching. Si el front-end envía un campo como string cuando el backend espera number, Zod lo captura antes del viaje de red.
```typescript
// En el componente React
import { createProductSchema } from '@/lib/schemas/product';
function ProductForm() {
const submit = async (data: unknown) => {
// Validación local antes de enviar
const result = createProductSchema.safeParse(data);
if (!result.success) {
setErrors(result.error.flatten().fieldErrors);
return;
}
await fetch('/api/products', {
method: 'POST',
body: JSON.stringify(result.data),
});
};
}
```
Objections Respondidas
"Uso tRPC — no necesito validación manual."
tRPC maneja la API principal. Pero seguís necesitando Route Handlers para webhooks de Stripe, callbacks de NextAuth, y endpoints externos. Esos paths bypassean tRPC y necesitan la misma disciplina de validación.
"Zod añade demasiado boilerplate para CRUD simple."
Un schema de 5 líneas (email, name, id) cuesta 60 segundos escribirlo. Un campo no validado causando un constraint violation en la base de datos cuesta 30 minutos de debugging. El ROI aparece en el tercer endpoint.
"Con HTTP status codes es suficiente."
Status codes categorizan errores (4xx vs 5xx). No dicen nada sobre la forma del cuerpo de error. Sin envelope consistente, cada handler de error en el front-end necesita lógica personalizada.
Resumen y Próximos Pasos
Route Handlers en Next.js App Router no son mágicamente type-safe porque TypeScript esté configurado. La seguridad real requiere validación runtime con Zod, middleware en el edge para auth, envoltorios genéricos que eliminen boilerplate, y contratos de respuesta consistentes.
El Patrón de Validación Jerárquica en 3 Capas os da la estructura para escalar de 5 a 50 endpoints sin que el código se degeneré.
Próximos pasos concretos:
1. Cread `/lib/schemas/` hoy — moved los primeros schemas de vuestras rutas más críticas
2. Implementad `createApiHandler` — el boilerplate que quitáis os saveará horas
3. Configurad `middleware.ts` — auth en el edge es más rápido y más seguro
4. Compartid schemas con el front-end — la misma validación funciona en ambos lados
TypeScript os da confianza. Zod os da seguridad. El patrón os da mantenibilidad.
Lee el artículo completo en brianmenagomez.com
Más sobre mis servicios en brianmenagomez.com
Herramientas: Conversor IAE CNAE · Gestorias cerca de ti · Calculadora IRPF

