Tema
Route Handlers (API)
Los Route Handlers te permiten crear endpoints de API dentro de tu proyecto Next.js: GET /api/productos, POST /api/webhooks/stripe, etc. Son ideales cuando otro sistema (una app móvil, un webhook, un cliente externo) necesita hablar con tu backend. Aprenderás a crearlos, leer parámetros y body, responder con los códigos correctos y protegerlos.
Concepto
Un Route Handler es un archivo route.ts dentro de app/ que exporta funciones con el nombre del método HTTP:
txt
app/
└── api/
├── salud/
│ └── route.ts → GET /api/salud
└── productos/
├── route.ts → GET, POST /api/productos
└── [id]/
└── route.ts → GET, PATCH, DELETE /api/productos/:idUsan las APIs estándar de la web: Request y Response (y las versiones extendidas NextRequest / NextResponse).
¿Route Handler o Server Action?
| Usa Server Actions para... | Usa Route Handlers para... |
|---|---|
| Formularios y mutaciones desde tu propia interfaz | APIs consumidas por apps móviles u otros servicios |
| Crear, editar, borrar desde componentes | Webhooks (Stripe, Mercado Pago, GitHub, un CMS) |
| Respuestas que no son HTML: JSON público, RSS, CSV, imágenes | |
| Integraciones que necesitan una URL fija |
Importante
Un archivo route.ts no puede estar en la misma carpeta que un page.tsx (ambos responderían a la misma URL). Por eso se agrupan en app/api/.
Tu primer Route Handler
ts
// app/api/salud/route.ts
export async function GET() {
return Response.json({ ok: true, hora: new Date().toISOString() })
}Visita http://localhost:3000/api/salud.
CRUD completo
ts
// "Base de datos" en memoria para el ejemplo (en la lección 12 usarás Prisma)
export type Producto = { id: number; nombre: string; precio: number; stock: number }
const productos: Producto[] = [
{ id: 1, nombre: 'Café de Chiapas', precio: 180, stock: 25 },
{ id: 2, nombre: 'Taza de barro', precio: 120, stock: 40 }
]
let siguienteId = 3
export const db = {
listar: (q?: string) =>
q ? productos.filter((p) => p.nombre.toLowerCase().includes(q.toLowerCase())) : productos,
obtener: (id: number) => productos.find((p) => p.id === id) ?? null,
crear: (datos: Omit<Producto, 'id'>) => {
const nuevo = { id: siguienteId++, ...datos }
productos.push(nuevo)
return nuevo
},
actualizar: (id: number, cambios: Partial<Omit<Producto, 'id'>>) => {
const p = productos.find((x) => x.id === id)
if (!p) return null
Object.assign(p, cambios)
return p
},
eliminar: (id: number) => {
const i = productos.findIndex((p) => p.id === id)
if (i === -1) return false
productos.splice(i, 1)
return true
}
}ts
import { z } from 'zod'
export const productoSchema = z.object({
nombre: z.string().trim().min(2).max(100),
precio: z.number().positive(),
stock: z.number().int().min(0).default(0)
})
export const idSchema = z.coerce.number().int().positive()
// Formatea errores de zod como { campo: mensaje }
export function erroresZod(error: z.ZodError) {
return Object.fromEntries(error.issues.map((i) => [i.path.join('.'), i.message]))
}ts
import { NextRequest } from 'next/server'
import { db } from '@/lib/productos-db'
import { productoSchema, erroresZod } from '@/lib/validaciones'
// GET /api/productos?q=cafe
export async function GET(request: NextRequest) {
const q = request.nextUrl.searchParams.get('q') ?? undefined
const datos = db.listar(q)
return Response.json({ datos, total: datos.length })
}
// POST /api/productos
export async function POST(request: NextRequest) {
let body: unknown
try {
body = await request.json()
} catch {
return Response.json({ error: 'JSON inválido' }, { status: 400 })
}
const resultado = productoSchema.safeParse(body)
if (!resultado.success) {
return Response.json(
{ error: 'Datos inválidos', detalles: erroresZod(resultado.error) },
{ status: 400 }
)
}
const producto = db.crear(resultado.data)
return Response.json(producto, {
status: 201,
headers: { Location: `/api/productos/${producto.id}` }
})
}ts
import { NextRequest } from 'next/server'
import { db } from '@/lib/productos-db'
import { productoSchema, idSchema, erroresZod } from '@/lib/validaciones'
// En Next.js 15+, params es una Promise (igual que en las páginas)
type Contexto = { params: Promise<{ id: string }> }
async function leerId(ctx: Contexto) {
const { id } = await ctx.params
const r = idSchema.safeParse(id)
return r.success ? r.data : null
}
export async function GET(_req: NextRequest, ctx: Contexto) {
const id = await leerId(ctx)
if (!id) return Response.json({ error: 'Id inválido' }, { status: 400 })
const producto = db.obtener(id)
if (!producto) return Response.json({ error: 'Producto no encontrado' }, { status: 404 })
return Response.json(producto)
}
export async function PATCH(request: NextRequest, ctx: Contexto) {
const id = await leerId(ctx)
if (!id) return Response.json({ error: 'Id inválido' }, { status: 400 })
const body = await request.json().catch(() => null)
const resultado = productoSchema.partial().safeParse(body)
if (!resultado.success) {
return Response.json({ error: 'Datos inválidos', detalles: erroresZod(resultado.error) }, { status: 400 })
}
const producto = db.actualizar(id, resultado.data)
if (!producto) return Response.json({ error: 'Producto no encontrado' }, { status: 404 })
return Response.json(producto)
}
export async function DELETE(_req: NextRequest, ctx: Contexto) {
const id = await leerId(ctx)
if (!id) return Response.json({ error: 'Id inválido' }, { status: 400 })
if (!db.eliminar(id)) return Response.json({ error: 'Producto no encontrado' }, { status: 404 })
return new Response(null, { status: 204 })
}Pruébalo:
bash
curl http://localhost:3000/api/productos
curl -X POST http://localhost:3000/api/productos -H "Content-Type: application/json" -d '{"nombre":"Molino","precio":650,"stock":5}'
curl -X PATCH http://localhost:3000/api/productos/1 -H "Content-Type: application/json" -d '{"precio":199}'
curl -X DELETE -i http://localhost:3000/api/productos/2Tipado automático de rutas
Next.js 15.5+ genera el tipo RouteContext<'/api/productos/[id]'> para tipar params automáticamente. Escribirlo a mano como { params: Promise<{ id: string }> } funciona en todas las versiones.
Leer datos de la petición
ts
import { NextRequest } from 'next/server'
import { cookies, headers } from 'next/headers'
export async function POST(request: NextRequest) {
// Query string
const pagina = request.nextUrl.searchParams.get('pagina')
// Headers
const auth = request.headers.get('authorization')
const listaHeaders = await headers() // alternativa
// Cookies
const token = request.cookies.get('token')?.value
const cookieStore = await cookies() // alternativa (también permite escribir)
// Body según el tipo
const json = await request.json() // application/json
// const form = await request.formData() // multipart/form-data o formularios
// const texto = await request.text() // texto plano (webhooks que verifican firma)
return Response.json({ pagina, auth: Boolean(auth), token: Boolean(token), json })
}Subir archivos
ts
// app/api/subir/route.ts
import { writeFile, mkdir } from 'node:fs/promises'
import path from 'node:path'
import crypto from 'node:crypto'
const TIPOS: Record<string, string> = { 'image/jpeg': '.jpg', 'image/png': '.png', 'image/webp': '.webp' }
export async function POST(request: Request) {
const form = await request.formData()
const archivo = form.get('archivo')
if (!(archivo instanceof File)) return Response.json({ error: 'Falta el archivo' }, { status: 400 })
if (!TIPOS[archivo.type]) return Response.json({ error: 'Tipo no permitido' }, { status: 400 })
if (archivo.size > 2 * 1024 * 1024) return Response.json({ error: 'Máximo 2 MB' }, { status: 400 })
const nombre = `${crypto.randomUUID()}${TIPOS[archivo.type]}`
const carpeta = path.join(process.cwd(), 'public', 'uploads')
await mkdir(carpeta, { recursive: true })
await writeFile(path.join(carpeta, nombre), Buffer.from(await archivo.arrayBuffer()))
return Response.json({ url: `/uploads/${nombre}` }, { status: 201 })
}En Vercel
El disco de Vercel (y de plataformas serverless) es de sólo lectura y temporal. En producción sube los archivos a un almacenamiento como Vercel Blob, S3, Cloudflare R2 o Cloudinary (ver Subida de archivos en Node).
Responder distintos formatos
ts
// app/api/reporte/route.ts — descarga un CSV
import { db } from '@/lib/productos-db'
export async function GET() {
const filas = db.listar()
const csv = ['id,nombre,precio,stock', ...filas.map((p) => `${p.id},"${p.nombre}",${p.precio},${p.stock}`)].join('\n')
return new Response('' + csv, {
headers: {
'Content-Type': 'text/csv; charset=utf-8',
'Content-Disposition': 'attachment; filename="productos.csv"'
}
})
}ts
// Redirección
import { redirect } from 'next/navigation'
export async function GET() {
redirect('/nueva-ruta')
}Caché en Route Handlers
Los GET son dinámicos por defecto. Para cachearlos como una página estática:
ts
// app/api/categorias/route.ts
export const revalidate = 3600 // se regenera cada hora
export async function GET() {
const res = await fetch('https://dummyjson.com/products/category-list')
return Response.json(await res.json())
}CORS: permitir otros dominios
Si una app en otro dominio (por ejemplo, una app móvil web o el sitio de un socio) consume tu API desde el navegador:
ts
// app/api/publica/route.ts
const ORIGENES = ['https://socio.com', 'http://localhost:5173']
function cabecerasCors(origen: string | null) {
const permitido = origen && ORIGENES.includes(origen) ? origen : ''
return {
'Access-Control-Allow-Origin': permitido,
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
Vary: 'Origin'
}
}
// Respuesta al preflight
export async function OPTIONS(request: Request) {
return new Response(null, { status: 204, headers: cabecerasCors(request.headers.get('origin')) })
}
export async function GET(request: Request) {
return Response.json({ mensaje: 'Hola desde la API' }, { headers: cabecerasCors(request.headers.get('origin')) })
}Más detalles sobre CORS en CORS y seguridad.
Proteger endpoints
Con API key (servicio a servicio)
ts
// lib/api-key.ts
import { timingSafeEqual } from 'node:crypto'
export function apiKeyValida(request: Request) {
const recibida = request.headers.get('x-api-key') ?? ''
const esperada = process.env.API_KEY ?? ''
if (!esperada || recibida.length !== esperada.length) return false
// Comparación en tiempo constante: evita ataques de tiempo
return timingSafeEqual(Buffer.from(recibida), Buffer.from(esperada))
}ts
// app/api/interna/pedidos/route.ts
import { apiKeyValida } from '@/lib/api-key'
export async function GET(request: Request) {
if (!apiKeyValida(request)) return Response.json({ error: 'No autorizado' }, { status: 401 })
return Response.json({ pedidos: [] })
}Con la sesión del usuario
Con Auth.js (lección 11):
ts
import { auth } from '@/auth'
export async function GET() {
const sesion = await auth()
if (!sesion?.user) return Response.json({ error: 'No autenticado' }, { status: 401 })
return Response.json({ usuario: sesion.user })
}Webhooks
Un webhook es una petición que otro servicio hace a tu API cuando ocurre algo (un pago aprobado, un pedido enviado). Verifica siempre la firma:
ts
// app/api/webhooks/pagos/route.ts
import crypto from 'node:crypto'
export async function POST(request: Request) {
const cuerpo = await request.text() // texto crudo: la firma se calcula sobre él
const firma = request.headers.get('x-firma') ?? ''
const esperada = crypto.createHmac('sha256', process.env.WEBHOOK_SECRET!).update(cuerpo).digest('hex')
const valida =
firma.length === esperada.length && crypto.timingSafeEqual(Buffer.from(firma), Buffer.from(esperada))
if (!valida) return Response.json({ error: 'Firma inválida' }, { status: 401 })
const evento = JSON.parse(cuerpo)
if (evento.tipo === 'pago.aprobado') {
// marcar el pedido como pagado en la base de datos...
}
return Response.json({ recibido: true })
}(Cada proveedor tiene su propio formato de firma; Stripe, por ejemplo, ofrece stripe.webhooks.constructEvent.)
Buena práctica
- Mantén la lógica de datos en
lib/y reutilízala desde Route Handlers, Server Actions y Server Components. - Responde siempre con el mismo formato de error (
{ error, detalles? }) y códigos HTTP correctos. - Valida todo con zod, igual que en una API de Express.
Error común
Hacer fetch('/api/...') desde un Server Component a tu propio Route Handler. Es una petición de red innecesaria. Llama directamente a la función de lib/.
Ejercicios
- Crea
GET /api/cotizacion?monto=1000&meses=12que calcule pagos mensuales con 2 % de interés mensual (fórmula de amortización) y valide los parámetros con zod. - Crea
GET /api/rssque devuelva un feed RSS (XML) con los posts del blog de la lección 5.
Ver solución
ts
// 1. app/api/cotizacion/route.ts
import { NextRequest } from 'next/server'
import { z } from 'zod'
const schema = z.object({
monto: z.coerce.number().positive().max(10_000_000),
meses: z.coerce.number().int().min(1).max(120)
})
export async function GET(request: NextRequest) {
const r = schema.safeParse(Object.fromEntries(request.nextUrl.searchParams))
if (!r.success) return Response.json({ error: 'Parámetros inválidos' }, { status: 400 })
const { monto, meses } = r.data
const i = 0.02
const pago = (monto * i) / (1 - Math.pow(1 + i, -meses))
return Response.json({
monto,
meses,
pagoMensual: Math.round(pago * 100) / 100,
totalPagado: Math.round(pago * meses * 100) / 100
})
}ts
// 2. app/api/rss/route.ts
import { obtenerPosts } from '@/lib/posts'
export const revalidate = 3600
const escapar = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
export async function GET() {
const sitio = process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000'
const posts = await obtenerPosts()
const items = posts
.map(
(p) => `
<item>
<title>${escapar(p.titulo)}</title>
<link>${sitio}/blog/${p.slug}</link>
<description>${escapar(p.resumen)}</description>
<pubDate>${new Date(p.fecha).toUTCString()}</pubDate>
</item>`
)
.join('')
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<title>Mi Blog</title>
<link>${sitio}</link>
<description>Artículos sobre desarrollo web</description>${items}
</channel>
</rss>`
return new Response(xml, { headers: { 'Content-Type': 'application/rss+xml; charset=utf-8' } })
}Resumen
- Un
route.tsexporta funcionesGET,POST,PATCH,DELETE... que recibenRequesty devuelvenResponse. paramses una Promise; lee query conrequest.nextUrl.searchParams, body conrequest.json()oformData().- Úsalos para APIs externas, webhooks y formatos no HTML; para tus propios formularios prefiere Server Actions.
- Protege con API keys, sesión o firmas de webhook, y valida siempre la entrada.