Skip to content

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/:id

Usan 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 interfazAPIs consumidas por apps móviles u otros servicios
Crear, editar, borrar desde componentesWebhooks (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/2

Tipado 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 ​

  1. Crea GET /api/cotizacion?monto=1000&meses=12 que calcule pagos mensuales con 2 % de interés mensual (fórmula de amortización) y valide los parámetros con zod.
  2. Crea GET /api/rss que 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')

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.ts exporta funciones GET, POST, PATCH, DELETE... que reciben Request y devuelven Response.
  • params es una Promise; lee query con request.nextUrl.searchParams, body con request.json() o formData().
  • Ú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.

Academia Web — aprende, copia y construye.