Módulo 12: Route Handlers y APIs

Objetivos del módulo

  • Entender qué es un Route Handler y en qué se diferencia de una página y de una Server Action
  • Crear ficheros route.ts con funciones GET, POST, PUT, PATCH, DELETE, HEAD y OPTIONS, y saber qué responde Next cuando falta un método
  • Leer todo lo que trae una petición: URL, parámetros de búsqueda, cabeceras, cookies y cuerpo en JSON, formulario o texto
  • Construir respuestas con Response y NextResponse: códigos de estado, cabeceras, cookies y redirecciones
  • Usar rutas dinámicas con params asíncronos y el tipo global RouteContext
  • Montar un backend for frontend para la aplicación EMPRESA, con validación Zod y errores con el mismo formato que la API
  • Saber cuándo next build convierte un GET en un fichero estático y cuándo lo ejecuta en cada petición, y cómo cachear datos con 'use cache' dentro de un handler
  • Enviar respuestas por partes (streaming): texto, NDJSON y Server-Sent Events con EventSource
  • Abrir tu API a otras webs con CORS sin abrirla a todo el mundo
  • Recibir webhooks firmados, comprobar la firma con HMAC y evitar los reenvíos
  • Reconocer los errores más habituales por su mensaje real

💡 Consejo: Todo lo que ves en este módulo se ha compilado y ejecutado con Next.js 16.4 y, para las alternativas marcadas, con Next.js 15.5. Instala siempre el último parche de la 16.4: los parches corrigen errores y fallos de seguridad, pero no cambian las APIs. Las salidas que aparecen (JSON, cabeceras HTTP, mensajes de error y salidas de next build) son reales, copiadas del laboratorio.


1. Qué es un Route Handler

Hasta ahora, cada URL de tu aplicación terminaba en un fichero page.tsx. Next ejecutaba un componente de React, le ponía alrededor los layouts y enviaba al navegador HTML y la carga útil de RSC. Eso es perfecto cuando quien pide la URL es una persona con un navegador que quiere ver una página.

Pero hay muchas peticiones que no buscan una página:

  • Un componente cliente que busca empleados mientras el usuario escribe necesita JSON, no HTML.
  • La aplicación móvil de la empresa quiere la lista de productos en JSON.
  • Contabilidad quiere descargar los pedidos en un CSV que abra Excel.
  • La pasarela de pagos avisa de que un pedido se ha pagado mandando un POST a una URL tuya: es un webhook.
  • Un lector de noticias quiere un fichero RSS con las novedades de la tienda.

Para todo eso Next tiene los Route Handlers: ficheros llamados route.ts que exportan funciones con el nombre de un método HTTP. Next llama a la función que corresponde, le pasa la petición, y tú devuelves la respuesta HTTP que quieras: JSON, texto, XML, un fichero, un stream o una redirección.

📘 Concepto: Un Route Handler es la pieza de más bajo nivel del App Router. No hay React, ni layouts, ni loading.tsx, ni error.tsx a su alrededor: entra un objeto Request del estándar web y sale un objeto Response del estándar web. Si ya has usado fetch en el navegador, conoces las dos clases: son las mismas, pero del lado del servidor.

Tres columnas: a la izquierda los clientes (navegador, formulario de React, cualquier cliente HTTP); en el centro el servidor de Next con page.tsx, Server Action y route.ts; a la derecha lo que devuelve cada uno: HTML y RSC, resultado de la acción, o lo que tú quieras

Figura 1. Las tres maneras de que el servidor de Next responda. Los Route Handlers son los únicos que exponen una URL pública y estable que puede llamar cualquier cliente HTTP.

1.1. Route Handlers, páginas y Server Actions

En el módulo 10 viste las Server Actions, que también ejecutan código en el servidor. ¿Por qué hace falta otra cosa? Porque resuelven problemas distintos:

  page.tsx Server Action route.ts
Quién la llama El navegador, al visitar la URL Tu propio código React (un <form> o una llamada) Cualquier cliente HTTP
URL La de la página Interna; Next la gestiona con un identificador Pública, la eliges tú
Métodos GET Siempre POST Los que exportes
Devuelve HTML + RSC Un valor serializable y UI actualizada Cualquier Response
Caso típico Mostrar datos Mutaciones desde tus formularios APIs, webhooks, ficheros, streams

Regla práctica que repetiremos en la sección 13: si quien llama es tu propia interfaz de React, empieza por un Server Component para leer y una Server Action para escribir. Usa un Route Handler cuando necesites una URL HTTP de verdad: otra aplicación, un servicio externo, una descarga o un formato que no sea UI.

1.2. Si vienes del Pages Router

En el Pages Router (lo verás en el módulo 24) lo equivalente eran las API Routes: ficheros en pages/api/ con una función handler(req, res) al estilo de Express. Los Route Handlers las sustituyen en el App Router con tres diferencias importantes:

  • Una función por método (export function GET) en lugar de un if (req.method === 'POST').
  • Usan Request y Response estándar, no los objetos de Node. Lo que aprendas aquí vale para otros entornos (Deno, Bun, Cloudflare Workers) que usan las mismas clases.
  • No hay bodyParser ni configuración para leer el cuerpo: llamas a request.json(), request.formData() o request.text() cuando lo necesitas.

2. Tu primer Route Handler

2.1. El fichero route.ts

Crea app/api/hola/route.ts:

// app/api/hola/route.ts
export async function GET() {
  return Response.json({ mensaje: 'Hola desde un Route Handler' })
}

Eso es todo. La ruta del fichero decide la URL igual que con las páginas: app/api/hola/route.ts responde en /api/hola. Arranca el servidor de desarrollo y ábrela:

npm run dev -- -p 3001
curl -i http://localhost:3001/api/hola
HTTP/1.1 200 OK
content-type: application/json
...
{"mensaje":"Hola desde un Route Handler"}

Response.json() es un método estático del estándar web (no es de Next): convierte el objeto con JSON.stringify, pone Content-Type: application/json y estado 200.

💡 Consejo: Como la API EMPRESA ocupa el puerto 3000, en este módulo Next se arranca en el 3001. Los ejemplos con curl usan -i para ver también el estado y las cabeceras. En Windows, curl.exe viene instalado; en PowerShell escribe curl.exe y no curl, que es un alias de otra orden.

Si abres un handler GET en el navegador, verás el JSON tal cual. Chrome y Edge muestran una casilla Pretty-print para formatearlo:

Ventana del navegador con la casilla Pretty-print arriba y el JSON del empleado 100 en una sola línea

Figura 2. El navegador mostrando la respuesta de GET /api/empleados/100. No hay HTML ni CSS: solo el cuerpo de la respuesta.

2.2. La carpeta api no es obligatoria

Muchos proyectos ponen los handlers dentro de app/api/ para que se vea a simple vista qué URL devuelven datos. Es una costumbre, no una regla: un route.ts funciona en cualquier carpeta de app. En este módulo usaremos app/pedidos.csv/route.ts para una descarga y app/novedades.xml/route.ts para un RSS; la URL lleva la extensión porque el nombre de la carpeta la lleva.

Lo que no puedes hacer es tener page.tsx y route.ts en la misma carpeta. Los dos reclaman la misma URL y Next no sabe a quién dársela. El build falla así:

> Build error occurred
Error: Turbopack build failed with 1 error:
./app
Error: An issue occurred while preparing your Next.js app
Conflicting route and page at /conflicto: route at /conflicto/route and page at /conflicto/page

En cambio, app/productos/page.tsx y app/api/productos/route.ts conviven sin problema: son URL distintas.

⚠️ Importante: Un route.ts se queda con todos los métodos de su URL. Si tienes app/contacto/page.tsx no puedes añadir un app/contacto/route.ts “solo para el POST”. Para recibir el formulario de esa página usa una Server Action (módulo 10) o pon el handler en otra URL, como app/api/contacto/route.ts.

2.3. Qué es NextRequest y qué es Request

Next llama a tu función con un objeto que es a la vez un Request estándar y un NextRequest, la subclase de Next con algunos extras. Puedes declarar el parámetro con el tipo que prefieras:

import type { NextRequest } from 'next/server'

export async function GET(request: Request) { /* solo lo estándar */ }
export async function GET(request: NextRequest) { /* + nextUrl, cookies... */ }

(Solo una de las dos, claro: un módulo no puede exportar dos GET.)

  Request (estándar) NextRequest (extras de Next)
URL request.url (cadena) request.nextUrl (objeto URL ya analizado)
Parámetros ?q= new URL(request.url).searchParams request.nextUrl.searchParams
Cabeceras request.headers igual
Cookies Analizar request.headers.get('cookie') a mano request.cookies.get('nombre')
Cuerpo json(), formData(), text(), arrayBuffer(), body igual

Lo mismo pasa con la respuesta: puedes devolver un Response estándar o un NextResponse, que añade NextResponse.json(), NextResponse.redirect() y respuesta.cookies.set(). En este módulo usaremos los dos y verás que se mezclan sin problema.


3. Los métodos HTTP

3.1. Una función por método

Un route.ts puede exportar funciones con estos nombres exactos, en mayúsculas: GET, POST, PUT, PATCH, DELETE, HEAD y OPTIONS. Cada una recibe dos parámetros opcionales, request y context, y debe devolver un Response (o una promesa de Response).

Repaso rápido de para qué se usa cada método en una API REST, que es el estilo de la API EMPRESA:

Método Uso habitual Ejemplo con EMPRESA Respuesta típica
GET Leer GET /api/empleados/100 200 con el recurso
POST Crear POST /api/empleados 201 con el recurso y cabecera Location
PUT Reemplazar entero PUT /api/empleados/161 200 con el recurso
PATCH Modificar en parte PATCH /api/pedidos/5003 (solo el estado) 200
DELETE Borrar DELETE /api/empleados/161 204 sin cuerpo
HEAD Como GET, pero solo cabeceras Comprobar si existe sin descargarlo 200 sin cuerpo
OPTIONS Preguntar qué se admite Preflight de CORS (sección 11) 204 con cabeceras

Si el repaso de HTTP te viene justo, el Curso de JavaScript explica fetch, los métodos y los códigos de estado desde el lado del cliente.

3.2. ¿Qué pasa si pides un método que no exportas?

Next responde 405 Method Not Allowed él solo. /api/hola solo exporta GET:

curl -i -X POST http://localhost:3001/api/hola
HTTP/1.1 405 Method Not Allowed

Method Not Allowed

No tienes que escribir nada para eso. Y hay dos métodos que Next completa si no los defines:

  • HEAD: si exportas GET, Next responde a HEAD con el estado y las cabeceras de tu GET, sin cuerpo. Con /api/hola, curl -I devuelve HTTP/1.1 200 OK y ningún cuerpo.
  • OPTIONS: si no lo exportas, Next responde con la cabecera Allow y la lista de métodos que sí tienes.

Esto es lo que respondió el laboratorio a un OPTIONS sobre /api/eco, que exporta GET y POST:

HTTP/1.1 204 No Content
allow: GET, HEAD, OPTIONS, POST

📘 Concepto: Hay un detalle curioso que conviene conocer. /api/hola es estático: next build lo ejecutó una vez y guardó la respuesta como un fichero (lo verás en la sección 8). A un handler estático, next start lo sirve como un fichero más, y un OPTIONS sobre él no llega a tu código: devuelve 405 con Allow: GET y Allow: HEAD. Solo importa si vas a recibir peticiones CORS con preflight en un handler estático; en ese caso exporta tú OPTIONS (sección 11) y compruébalo con curl -X OPTIONS tras el build.

Diagrama en cinco pasos: llega DELETE /api/empleados/161, Next busca app/api/empleados/[id]/route.ts, comprueba si exporta DELETE (si no, 405), ejecuta tu código y envía la respuesta HTTP. Un recuadro aparte enumera lo que no ocurre: ni layouts, ni JSX, ni navegación

Figura 3. Lo que hace Next con una petición a un Route Handler.

3.3. Siempre hay que devolver algo

El error más tonto y más frecuente es olvidar el return. TypeScript no protesta (una función async que no devuelve nada es válida), pero Next sí. Este handler:

// app/api/sin-respuesta/route.ts
export async function GET() {
  Response.json({ mensaje: 'nunca llega' }) // falta el return
}

hace fallar next build, porque Next intenta prerenderizarlo y no recibe nada:

Error occurred prerendering page "/api/sin-respuesta". Read more: https://nextjs.org/docs/messages/prerender-error
Error: No response is returned from route handler '[project]/app/api/sin-respuesta/route.ts'. Expected a Response object but received 'undefined' (method: GET, url: /api/sin-respuesta). Ensure you return a `Response` or a `NextResponse` in all branches of your handler.

Si el handler es dinámico (por ejemplo, con await connection() delante), el build pasa y el error aparece cuando alguien lo llama. El cliente recibe HTTP/1.1 500 Internal Server Error y la consola del servidor muestra el mismo mensaje, precedido de ⨯ Error:. La frase final del mensaje es el consejo bueno: todas las ramas deben devolver un Response, también la del if de error que escribiste deprisa.


4. Leer la petición

Para ver todo lo que se puede sacar de una petición, el laboratorio tiene un handler “eco” que devuelve lo que recibe:

// app/api/eco/route.ts
import { type NextRequest, NextResponse } from 'next/server'
import { cookies, headers } from 'next/headers'

export async function GET(request: NextRequest) {
  const url = request.nextUrl
  const cabeceras = await headers()
  const almacen = await cookies()

  return NextResponse.json({
    metodo: request.method,
    ruta: url.pathname,
    q: url.searchParams.get('q'),
    etiquetas: url.searchParams.getAll('etiqueta'),
    agente: cabeceras.get('user-agent'),
    tema: almacen.get('tema')?.value ?? null,
    temaDesdeRequest: request.cookies.get('tema')?.value ?? null,
  })
}
curl "http://localhost:3001/api/eco?q=garcia&etiqueta=a&etiqueta=b" -H "Cookie: tema=oscuro" -A "curl/8"
{"metodo":"GET","ruta":"/api/eco","q":"garcia","etiquetas":["a","b"],"agente":"curl/8","tema":"oscuro","temaDesdeRequest":"oscuro"}

4.1. La URL y los parámetros de búsqueda

request.nextUrl es un objeto URL con todo ya separado: pathname, searchParams, origin… Con searchParams:

  • get('q') devuelve el primer valor o null si no está. Siempre es una cadena: ?pagina=2 te da '2', no 2.
  • getAll('etiqueta') devuelve todos los valores de un parámetro repetido.
  • has('q') dice si está, aunque esté vacío (?q=).

⚠️ Importante: Todo lo que llega en la URL es texto que escribe quien hace la petición. Antes de usarlo en una consulta conviértelo y valídalo: Number(searchParams.get('pagina') ?? '1') puede dar NaN si alguien escribe ?pagina=hola. En la sección 7 lo validaremos con Zod.

4.2. Cabeceras y cookies: dos caminos

Hay dos formas de leer cabeceras y cookies, y las dos dan lo mismo:

  • Desde la petición: request.headers.get('user-agent') y request.cookies.get('tema').
  • Con las funciones de next/headers: (await headers()).get(...) y (await cookies()).get(...), las mismas que usas en Server Components y Server Actions.

¿Cuál usar? Dentro de un handler, la petición está a mano y es lo más directo. Las funciones de next/headers son útiles cuando la lectura está en una función auxiliar que no recibe request, por ejemplo una función obtenerSesion() de tu capa de datos que también se usa desde páginas (lo verás en el módulo 17).

Las dos funciones de next/headers son asíncronas desde Next.js 15: el await es obligatorio, como viste con params en el módulo 04.

📘 Concepto: El objeto de headers() es de solo lectura: no puedes añadir cabeceras a la respuesta con él. Las cabeceras de respuesta van en el Response que devuelves. cookies(), en cambio, sí permite set y delete dentro de un Route Handler o una Server Action, y Next añade el Set-Cookie a tu respuesta (sección 5.4).

4.3. El cuerpo: json(), formData() y text()

El cuerpo se lee con métodos asíncronos del estándar:

Método Para Qué devuelve
await request.json() Content-Type: application/json El objeto ya analizado
await request.formData() Formularios HTML (multipart/form-data o x-www-form-urlencoded) Un FormData
await request.text() Cualquier cosa; imprescindible en webhooks La cadena tal cual llegó
await request.arrayBuffer() / request.body Binarios, ficheros grandes Bytes / un ReadableStream

El POST del handler eco decide según el Content-Type:

// app/api/eco/route.ts (continuación)
export async function POST(request: NextRequest) {
  const tipo = request.headers.get('content-type') ?? ''

  if (tipo.includes('application/json')) {
    try {
      const cuerpo = await request.json()
      return NextResponse.json({ recibido: cuerpo }, { status: 201 })
    } catch {
      return NextResponse.json({ status: 400, mensaje: 'El cuerpo no es JSON válido' }, { status: 400 })
    }
  }

  if (tipo.includes('multipart/form-data') || tipo.includes('application/x-www-form-urlencoded')) {
    const datos = await request.formData()
    return NextResponse.json({ recibido: Object.fromEntries(datos) }, { status: 201 })
  }

  const texto = await request.text()
  return NextResponse.json({ recibido: texto, tipo }, { status: 201 })
}

Las tres pruebas, con su salida real:

curl -X POST http://localhost:3001/api/eco -H "Content-Type: application/json" -d '{"nombre":"Ana"}'
# 201 {"recibido":{"nombre":"Ana"}}

curl -X POST http://localhost:3001/api/eco -H "Content-Type: application/json" -d '{nombre:'
# 400 {"status":400,"mensaje":"El cuerpo no es JSON válido"}

curl -X POST http://localhost:3001/api/eco -F nombre=Ana -F edad=30
# 201 {"recibido":{"nombre":"Ana","edad":"30"}}

Tres detalles que causan muchos errores:

  1. request.json() lanza una excepción si el cuerpo no es JSON válido. Sin el try/catch, el cliente recibiría un 500 (“error del servidor”) cuando el error es suyo; lo correcto es un 400 (“tu petición está mal”).
  2. El cuerpo solo se puede leer una vez. Es un stream: cuando lo consumes con json(), ya no puedes llamar a text(). Si necesitas el texto y el objeto, lee text() y luego haz JSON.parse(texto) tú. Así trabajan los webhooks (sección 12).
  3. Los valores de FormData son siempre cadenas (o File si es un fichero): fíjate en "edad":"30". Convierte y valida antes de usarlos; en el módulo 11 viste cómo hacerlo con Zod.

4.4. request.url y el nombre del servidor

Un detalle que sorprende. El laboratorio arranca next start -p 5512 -H 127.0.0.1 y le pide el eco a http://127.0.0.1:5512/api/eco, con tres líneas más en el handler (url: request.url, origen: url.origin y host: cabeceras.get('host')). Respuesta real:

{"metodo":"GET","ruta":"/api/eco","url":"http://localhost:5512/api/eco","origen":"http://localhost:5512","host":"127.0.0.1:5512", ...}

request.url y nextUrl.origin dicen localhost, aunque el cliente escribió 127.0.0.1, como dice la cabecera Host. Next reconstruye la URL con su propio nombre, no con el que usó el cliente. En tu ordenador da igual, pero en producción, detrás de un proxy o un balanceador, la URL que ve el handler puede no ser la pública. Consecuencias prácticas:

  • Para redirigir dentro de tu sitio, usa rutas relativas siempre que puedas (redirect('/seguimiento'), sección 5.5).
  • Para construir URL absolutas públicas (un enlace en un correo, el <link> de un RSS), no te fíes de request.url: guarda la URL pública en una variable de entorno, como https://tienda.empresa.es (lo verás en el módulo 13).

5. Construir la respuesta

5.1. Estado y cabeceras

Tanto new Response(cuerpo, opciones) como Response.json(datos, opciones) aceptan un segundo argumento con status, statusText y headers:

return Response.json(empleado, {
  status: 201,
  headers: { Location: `/api/empleados/${empleado.empId}` },
})

Los códigos que más usarás en una API:

Código Significa Cuándo
200 OK Lectura o modificación correcta
201 Created Has creado algo; añade Location con su URL
204 No Content Correcto y sin cuerpo (típico de DELETE)
303 See Other Redirige con GET tras un POST
307 Temporary Redirect Redirige conservando el método
400 Bad Request La petición está mal formada (JSON roto, id que no es número)
401 Unauthorized Falta identificarse o la credencial no vale
403 Forbidden Identificado, pero sin permiso
404 Not Found El recurso no existe
405 Method Not Allowed Método no exportado (lo pone Next)
409 Conflict Choca con el estado actual (borrar un departamento con empleados)
415 Unsupported Media Type No sabes leer ese Content-Type
422 Unprocessable Content Bien formada, pero los datos no son válidos
500 Internal Server Error Fallo tuyo (o un throw sin capturar)
502 / 503 Bad Gateway / Service Unavailable Un servicio del que dependes ha fallado

5.2. Un formato de error único

Una buena API responde a todos sus errores con la misma forma, para que el cliente tenga un solo código de tratamiento de errores. La API EMPRESA usa { status, mensaje, detalles? }. Nuestros handlers usarán lo mismo con una pequeña función:

// lib/api.ts
import 'server-only'

export const API = process.env.EMPRESA_API ?? 'http://localhost:3000/api'

// Error con el formato de la API EMPRESA: { status, mensaje, detalles? }
export function error(status: number, mensaje: string, detalles?: Record<string, string>) {
  return Response.json({ status, mensaje, detalles }, { status })
}

import 'server-only' (del módulo 05) garantiza que este fichero nunca acabe en el paquete del navegador. EMPRESA_API es una variable de entorno que definimos en .env.local; las variables de entorno se estudian en el módulo 13, y de momento basta con esto:

# .env.local
EMPRESA_API=http://localhost:3000/api

JSON.stringify omite las propiedades undefined, así que si no pasas detalles no aparece en la respuesta.

5.3. Respuestas sin cuerpo

Para un 204, el cuerpo debe ser null:

return new Response(null, { status: 204 })

No uses Response.json(null, { status: 204 }): escribe el texto null en el cuerpo, y una respuesta 204 no puede llevar cuerpo.

5.4. Cookies

Hay dos formas de crear una cookie en la respuesta, y el laboratorio usa las dos en un handler que guarda el tema de color preferido:

// app/api/tema/route.ts
import { type NextRequest, NextResponse } from 'next/server'
import { cookies } from 'next/headers'

// GET /api/tema?valor=oscuro  -> guarda la preferencia y vuelve a la página de origen
export async function GET(request: NextRequest) {
  const valor = request.nextUrl.searchParams.get('valor')
  const destino = new URL('/', request.url)

  const respuesta = NextResponse.redirect(destino, 303)
  if (valor === 'claro' || valor === 'oscuro') {
    respuesta.cookies.set('tema', valor, { path: '/', maxAge: 60 * 60 * 24 * 365, sameSite: 'lax' })
  }
  return respuesta
}

// DELETE /api/tema  -> borra la preferencia con cookies() de next/headers
export async function DELETE() {
  const almacen = await cookies()
  almacen.delete('tema')
  return new Response(null, { status: 204 })
}

Cabeceras reales de las dos respuestas:

GET /api/tema?valor=oscuro
303 location: http://localhost:5512/
set-cookie: tema=oscuro; Path=/; Expires=Tue, 02 Nov 2027 11:52:32 GMT; Max-Age=31536000; SameSite=lax

DELETE /api/tema
204 set-cookie: tema=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT
  • respuesta.cookies.set() de NextResponse escribe en esa respuesta.
  • (await cookies()).set() o .delete() escriben en la respuesta que Next acabe enviando, sea cual sea. Borrar una cookie es, en realidad, enviarla vacía y caducada en 1970.

La location dice localhost, aunque la petición fue a 127.0.0.1: es el efecto de request.url de la sección 4.4.

⚠️ Importante: Las cookies de sesión llevan además httpOnly: true (JavaScript del navegador no puede leerlas) y secure: true (solo por HTTPS). Lo verás con calma en el módulo 17. Una cookie de preferencia de tema no es secreta y no lo necesita.

5.5. Redirecciones

Hay dos formas de redirigir desde un handler:

import { redirect } from 'next/navigation'
import { NextResponse } from 'next/server'

redirect('/seguimiento')                                  // lanza: 307 con location relativa
return NextResponse.redirect(new URL('/', request.url), 303) // devuelve: URL absoluta obligatoria

Con el handler de prueba app/api/ir/route.ts, que llama a redirect('/seguimiento'):

HTTP/1.1 307 Temporary Redirect
location: /seguimiento
  redirect() de next/navigation NextResponse.redirect()
Cómo se usa Se llama; lanza una excepción especial Se devuelve
URL Relativa o absoluta Absoluta (un URL o cadena completa)
Código 307 (o 308 con permanentRedirect) 307 por defecto; el que pases en el segundo argumento
Permite cookies Con cookies() antes de llamarla Con respuesta.cookies.set()

Como redirect() funciona lanzando una excepción, no lo metas dentro de un try/catch o tu catch se la tragará. Ya lo viste con las páginas en el módulo 03; en un handler pasa exactamente lo mismo.

💡 Consejo: Tras un POST de formulario, redirige con 303: le dice al navegador que pida el destino con GET. Con 307 el navegador repetiría el POST contra la nueva URL, que casi nunca es lo que quieres.


6. Rutas dinámicas y RouteContext

6.1. params es una promesa

Los segmentos dinámicos funcionan igual que con las páginas (módulo 04): app/api/empleados/[id]/route.ts responde a /api/empleados/100, /api/empleados/161… El valor llega en el segundo parámetro, el contexto, dentro de una promesa:

// app/api/empleados/[id]/route.ts
import type { NextRequest } from 'next/server'
import { API, error } from '@/lib/api'

function idValido(id: string) {
  return /^\d+$/.test(id)
}

export async function GET(_request: NextRequest, ctx: RouteContext<'/api/empleados/[id]'>) {
  const { id } = await ctx.params
  if (!idValido(id)) return error(400, `"${id}" no es un identificador`)

  const res = await fetch(`${API}/empleados/${id}`)
  if (res.status === 404) return error(404, `No existe el empleado ${id}`)
  return Response.json(await res.json())
}
GET /api/empleados/999  ->  404 {"status":404,"mensaje":"No existe el empleado 999"}
GET /api/empleados/abc  ->  400 {"status":400,"mensaje":"\"abc\" no es un identificador"}

El guion bajo de _request es una convención para decir “este parámetro no lo uso, pero tengo que declararlo para llegar al segundo”.

6.2. El tipo RouteContext

RouteContext<'/api/empleados/[id]'> es un tipo global (no se importa) que Next genera a partir de tus carpetas. Sabe que esa ruta tiene un parámetro id de tipo string, así que ctx.params queda tipado como Promise<{ id: string }>. Si escribes mal la ruta ('/api/empleado/[id]'), TypeScript protesta.

Los tipos se generan al ejecutar next dev, next build o next typegen (lo viste en el módulo 01). Si acabas de crear la carpeta y el editor no reconoce la ruta, deja next dev en marcha o ejecuta npx next typegen.

Sin RouteContext, tendrías que escribir el tipo a mano, que es lo que verás en mucho código existente:

export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
  const { id } = await params
}
Carpeta URL await params
app/api/empleados/[id]/route.ts /api/empleados/100 { id: '100' }
app/api/departamentos/[id]/empleados/route.ts /api/departamentos/30/empleados { id: '30' }
app/api/archivos/[...ruta]/route.ts /api/archivos/2025/enero.pdf { ruta: ['2025', 'enero.pdf'] }

📘 Concepto: params es asíncrono y RouteContext existe desde Next.js 15 (el tipo global apareció en la 15.5), así que este apartado funciona igual en Next.js 15. En código de Next.js 14 verás { params }: { params: { id: string } } sin promesa; si lo copias en 15 o 16, el await que falta hace que params.id sea undefined.


7. Caso práctico: una API para el panel de empleados

Vamos a construir lo que en la jerga se llama un backend for frontend (BFF): una pequeña API propia, hecha a la medida de nuestra interfaz, que se pone delante de la API EMPRESA.

¿Por qué no llamar a la API EMPRESA directamente desde el navegador? Por cuatro motivos que verás en proyectos reales:

  1. Decides qué sale. La API EMPRESA devuelve el email, el teléfono y el salario de cada empleado. El buscador solo necesita nombre, oficio y departamento. Lo que no envías no se puede filtrar.
  2. Escondes la dirección y las credenciales. El navegador solo conoce /api/empleados. La URL de la API interna, y cualquier clave para llamarla, se quedan en el servidor.
  3. Mismo origen. Página y API están en el mismo dominio, así que no hay CORS de por medio (sección 11).
  4. Validas antes de molestar a nadie. Los datos malos se rechazan con un 422 sin llegar a la API interna.

7.1. Listar con filtros

// app/api/empleados/route.ts
import { type NextRequest, NextResponse } from 'next/server'
import * as z from 'zod'
import { API, error, type Empleado, type Lista } from '@/lib/api'

// GET /api/empleados?q=garcia&pagina=1
export async function GET(request: NextRequest) {
  const entrada = request.nextUrl.searchParams
  const salida = new URLSearchParams()
  for (const clave of ['q', 'pagina', 'deptId']) {
    const valor = entrada.get(clave)
    if (valor) salida.set(clave, valor)
  }
  salida.set('tamano', '5')

  const res = await fetch(`${API}/empleados?${salida}`)
  if (!res.ok) return error(502, 'La API EMPRESA no responde')
  const lista: Lista<Empleado> = await res.json()

  // Solo lo que necesita la interfaz: ni email ni salario
  return NextResponse.json({
    total: lista.total,
    pagina: lista.pagina,
    paginas: lista.paginas,
    empleados: lista.contenido.map((e) => ({
      id: e.empId,
      nombre: `${e.nombre} ${e.apellidos}`,
      oficio: e.oficio,
      departamento: e.departamento,
    })),
  })
}

Los tipos Empleado y Lista<T> están en lib/api.ts:

// lib/api.ts (continuación)
export type Empleado = {
  empId: number
  nombre: string
  apellidos: string
  email: string
  oficio: string
  salario: number
  deptId: number
  departamento: string
}

export type Lista<T> = {
  contenido: T[]
  pagina: number
  tamano: number
  total: number
  paginas: number
}

Observa la lista blanca de parámetros: solo reenviamos q, pagina y deptId, y fijamos tamano nosotros. Si alguien llama a /api/empleados?tamano=100000, el parámetro se ignora. Reenviar request.nextUrl.search tal cual sería más corto, pero le daría al cliente todo el control sobre la API interna.

curl "http://localhost:3001/api/empleados?q=garcia"
{"total":1,"pagina":1,"paginas":1,"empleados":[{"id":100,"nombre":"Ana García López","oficio":"DIRECTOR GENERAL","departamento":"DIRECCIÓN"}]}

7.2. Consumirlo desde un componente cliente

Un buscador que filtra mientras escribes es un caso donde un Route Handler encaja bien: el componente cliente necesita datos nuevos en cada pulsación, sin recargar la página.

// app/buscador-empleados.tsx
'use client'

import { useEffect, useState } from 'react'

type Respuesta = {
  total: number
  empleados: { id: number; nombre: string; oficio: string; departamento: string }[]
}

export function BuscadorEmpleados() {
  const [texto, setTexto] = useState('')
  const [datos, setDatos] = useState<Respuesta | null>(null)
  const [error, setError] = useState<string | null>(null)

  useEffect(() => {
    const controlador = new AbortController()
    const temporizador = setTimeout(async () => {
      try {
        const res = await fetch(`/api/empleados?q=${encodeURIComponent(texto)}`, {
          signal: controlador.signal,
        })
        if (!res.ok) throw new Error((await res.json()).mensaje)
        setDatos(await res.json())
        setError(null)
      } catch (e) {
        if (!controlador.signal.aborted) setError((e as Error).message)
      }
    }, 300)
    return () => {
      clearTimeout(temporizador)
      controlador.abort()
    }
  }, [texto])

  return (
    <div>
      <input
        value={texto}
        onChange={(e) => setTexto(e.target.value)}
        placeholder="Buscar por nombre, apellidos u oficio"
        className="mb-4 w-full rounded border border-slate-300 px-3 py-2"
      />
      {error && <p className="text-red-700">{error}</p>}
      {datos && (
        <>
          <p className="mb-2 text-sm text-slate-500">{datos.total} resultados (máximo 5 por página)</p>
          <ul className="divide-y divide-slate-200 rounded border border-slate-200">
            {datos.empleados.map((e) => (
              <li key={e.id} className="flex justify-between px-3 py-2">
                <span className="font-medium">{e.nombre}</span>
                <span className="text-sm text-slate-500">
                  {e.oficio} · {e.departamento}
                </span>
              </li>
            ))}
          </ul>
        </>
      )}
    </div>
  )
}
// app/page.tsx
import { BuscadorEmpleados } from './buscador-empleados'

export default function Inicio() {
  return (
    <main className="mx-auto max-w-2xl p-8">
      <h1 className="mb-1 text-2xl font-bold text-indigo-900">Empleados</h1>
      <p className="mb-6 text-sm text-slate-600">
        Los datos llegan de <code>/api/empleados</code>, un Route Handler que llama a la API EMPRESA.
      </p>
      <BuscadorEmpleados />
    </main>
  )
}

Dos técnicas de React que merece la pena señalar, aunque no son de Next:

  • El setTimeout de 300 ms es un debounce: no se pide nada hasta que el usuario deja de escribir un momento. Sin él, escribir “martinez” lanzaría ocho peticiones.
  • El AbortController cancela la petición anterior si el texto cambia antes de que llegue la respuesta. Así una respuesta lenta de “mar” no puede pisar a una rápida de “mart”. Si quieres repasarlo, el Curso de React lo trata al hablar de efectos y datos.

Página Empleados con el buscador; se ha escrito mar y aparecen 4 resultados: Luis Martínez Ruiz, Marta Sánchez Gil, Hugo Rubio Marín y Marta Vidal Ortega, cada uno con su oficio y departamento

Figura 4. El buscador después de escribir “mar”. Cada pulsación (con *debounce) llama a /api/empleados, que llama a la API EMPRESA y recorta los campos.*

💡 Consejo: Si tus pantallas hacen muchas consultas así (paginación, caché en el cliente, reintentos), una biblioteca como TanStack Query te ahorra todo el useEffect. La usaremos junto a los Route Handlers en el módulo 18.

7.3. Crear con validación

El POST hace cuatro comprobaciones antes de tocar la API: credencial, JSON válido, datos válidos y, al final, lo que diga la API.

// app/api/empleados/route.ts (continuación)
const EsquemaEmpleado = z.object({
  nombre: z.string().trim().min(2, 'Mínimo 2 caracteres'),
  apellidos: z.string().trim().min(2, 'Mínimo 2 caracteres'),
  email: z.email('El email no es válido'),
  oficio: z.string().trim().min(2),
  salario: z.number().positive('Debe ser mayor que 0'),
  deptId: z.number().int(),
})

// POST /api/empleados
export async function POST(request: NextRequest) {
  const token = request.headers.get('authorization')
  if (!token) return error(401, 'Falta la cabecera Authorization')

  let cuerpo: unknown
  try {
    cuerpo = await request.json()
  } catch {
    return error(400, 'El cuerpo no es JSON válido')
  }

  const resultado = EsquemaEmpleado.safeParse(cuerpo)
  if (!resultado.success) {
    const detalles = Object.fromEntries(
      resultado.error.issues.map((i) => [i.path.join('.'), i.message]),
    )
    return error(422, 'Hay datos no válidos', detalles)
  }

  const res = await fetch(`${API}/empleados`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', Authorization: token },
    body: JSON.stringify({ ...resultado.data, fechaAlta: new Date().toISOString().slice(0, 10) }),
  })
  const datos = await res.json()
  if (!res.ok) return NextResponse.json(datos, { status: res.status })

  return NextResponse.json(datos, {
    status: 201,
    headers: { Location: `/api/empleados/${datos.empId}` },
  })
}

El token se obtiene haciendo login en la API EMPRESA (POST /api/auth/login con luis.martinez@empresa.es / secreto, rol USER). Respuestas reales, en orden:

POST sin Authorization
401 {"status":401,"mensaje":"Falta la cabecera Authorization"}

POST con cuerpo "{"
400 {"status":400,"mensaje":"El cuerpo no es JSON válido"}

POST {"nombre":"A","apellidos":"Pérez","email":"no-es-email","oficio":"ANALISTA","salario":-5,"deptId":40}
422 {"status":422,"mensaje":"Hay datos no válidos","detalles":{"nombre":"Mínimo 2 caracteres","email":"El email no es válido","salario":"Debe ser mayor que 0"}}

POST {"nombre":"Marta","apellidos":"Vidal Ortega","email":"marta.vidal@empresa.es","oficio":"ANALISTA","salario":2900,"deptId":40}
201 location: /api/empleados/161
{"empId":161,"nombre":"Marta","apellidos":"Vidal Ortega","email":"marta.vidal@empresa.es","telefono":null,"fechaAlta":"2026-11-02","oficio":"ANALISTA","salario":2900,"comision":null,"jefeId":null,"deptId":40,"departamento":"CONTABILIDAD"}

Zod 4 y safeParse ya los conoces del módulo 11. Lo nuevo aquí es traducir sus errores al formato de la API (detalles con un mensaje por campo), para que el cliente los trate igual vengan de nuestro BFF o de la API EMPRESA.

7.4. Modificar y borrar

// app/api/empleados/[id]/route.ts (continuación)
export async function PUT(request: NextRequest, ctx: RouteContext<'/api/empleados/[id]'>) {
  const { id } = await ctx.params
  const res = await fetch(`${API}/empleados/${id}`, {
    method: 'PUT',
    headers: {
      'Content-Type': 'application/json',
      Authorization: request.headers.get('authorization') ?? '',
    },
    body: await request.text(),
  })
  return Response.json(await res.json(), { status: res.status })
}

export async function DELETE(request: NextRequest, ctx: RouteContext<'/api/empleados/[id]'>) {
  const { id } = await ctx.params
  const res = await fetch(`${API}/empleados/${id}`, {
    method: 'DELETE',
    headers: { Authorization: request.headers.get('authorization') ?? '' },
  })
  if (res.status === 204) return new Response(null, { status: 204 })
  return Response.json(await res.json(), { status: res.status })
}

Aquí el handler es un simple intermediario: reenvía el cuerpo y la credencial, y devuelve el mismo estado que la API. Dos respuestas reales que enseñan algo:

PUT /api/empleados/161  con {"salario":3100}
422 {"status":422,"mensaje":"Hay datos no válidos","detalles":{"nombre":"El nombre es obligatorio","apellidos":"Los apellidos son obligatorios","email":"El email no es válido","oficio":"El oficio es obligatorio"}}

DELETE /api/empleados/161  como USER
403 {"status":403,"mensaje":"Solo un administrador puede hacer esto"}
  • PUT significa reemplazar el recurso entero: si solo mandas el salario, la API entiende que el resto queda vacío y lo rechaza. Para cambiar un campo suelto, lo correcto sería PATCH (en la API EMPRESA solo existe para el estado de los pedidos).
  • El 403 viene de la API EMPRESA y nuestro handler lo deja pasar tal cual. Como los dos usan el mismo formato de error, el cliente no nota la diferencia.

⚠️ Importante: Este intermediario confía en la API interna para comprobar permisos. Si en tu proyecto el Route Handler accede directamente a la base de datos (lo harás desde el módulo 16), es el handler quien debe comprobar en cada método quién llama y qué puede hacer. Un Route Handler es una URL pública: cualquiera puede llamarla con curl, aunque tu interfaz no tenga un botón para ello.


8. Caché: cuándo un GET es estático 🔄 16.0

🔄 Cambia en Next.js 16.0. En Next.js 15, sin Cache Components, todo GET es dinámico salvo que lo marques, y no hay 'use cache', cacheLife ni cacheTag estables. Si usas Next.js 15: añade al fichero export const dynamic = 'force-static' o export const revalidate = 3600, etiqueta los fetch con next: { tags: ['productos'] } (o usa unstable_cache) y llama a revalidateTag('productos') con un solo argumento (sección 8.6).

8.1. Qué hace next build con tus handlers

Igual que con las páginas (módulo 02 y módulo 09), durante next build Next ejecuta cada GET para ver si puede guardar la respuesta como un fichero. Con cacheComponents: true (lo que trae create-next-app en 16.4), la regla es la misma que para las páginas:

  • Si el GET no toca nada que dependa de la petición ni de datos sin caché, su respuesta se guarda en el build: es estático (○).
  • Si toca algo así, la prerenderización se detiene y el handler se ejecuta en cada petición (ƒ).
  • Los demás métodos (POST, PUT, PATCH, DELETE…) nunca se cachean.

¿Qué “toca la petición”? Según la documentación, y lo hemos comprobado en el laboratorio: leer request.url, request.nextUrl, request.headers, request.cookies o el cuerpo; llamar a cookies(), headers() o connection(); hacer un fetch o una consulta a base de datos sin caché; usar new Date() o Math.random().

Esta es la tabla real del build del laboratorio (recortada a lo que importa):

Route (app)                            Revalidate  Expire
├ ○ /api/catalogo                              1h      1d
├   /api/catalogo/[id]
│ ├ ● /api/catalogo/1                          1h      1d
│ ├ ● /api/catalogo/2                          1h      1d
│ └ ● /api/catalogo/3                          1h      1d
├ ○ /api/categorias                            1d      1w
├ ƒ /api/eco
├ ƒ /api/empleados
├ ○ /api/hola
├ ƒ /api/hora
├ ƒ /api/hora-congelada
├ ƒ /api/publico/productos
├ ○ /novedades.xml                             1h      1d
├ ƒ /pedidos.csv
...
○  (Static)   prerendered as static content
●  (SSG)      prerendered as static HTML (uses generateStaticParams)
ƒ  (Dynamic)  server-rendered on demand

Árbol de decisión: Next ejecuta tu GET en el build. Si solo devuelve datos fijos, es estático. Si sus datos están dentro de use cache, es estático con Revalidate y Expire. Si depende de la petición (cabeceras, cookies, connection, fetch sin caché, fecha o aleatorios), es dinámico. Nota: POST, PUT, PATCH y DELETE nunca se cachean

Figura 5. Qué decide next build para cada GET con Cache Components.

8.2. Estático: /api/hola

/api/hola solo devuelve un objeto fijo, así que sale ○. Al pedirlo con next start, las cabeceras lo delatan:

HTTP/1.1 200 OK
x-nextjs-cache: HIT
cache-control: s-maxage=31536000
content-type: application/json

x-nextjs-cache: HIT indica que la respuesta salió de la caché de Next, sin ejecutar tu función. s-maxage=31536000 (un año) permite a una CDN guardarla también.

8.3. Dinámico: la hora

// app/api/hora/route.ts
import { connection } from 'next/server'

export async function GET() {
  await connection() // "esto depende de la petición": no se prerenderiza
  return Response.json({ hora: new Date().toISOString() })
}

connection() no hace nada visible: solo le dice a Next “a partir de aquí, espera a que haya una petición real”. Es la forma explícita de pedir un handler dinámico, y la que recomienda la documentación en lugar del antiguo export const dynamic = 'force-dynamic' (que con cacheComponents rompe el build, como viste en el módulo 09).

¿Y si quitas connection()? El laboratorio tiene /api/hora-congelada, idéntico pero sin esa línea. El build lo marca igualmente ƒ: Next detecta new Date() y deja de prerenderizar. Dos peticiones seguidas dan horas distintas:

{"hora":"2026-11-02T11:51:19.890Z"}
{"hora":"2026-11-02T11:51:21.787Z"}

Aun así, pon connection(): deja clara la intención a quien lea el código y no depende de que Next detecte cada caso.

8.4. Datos cacheados con 'use cache'

El catálogo de la tienda cambia poco, y no queremos ir a la API en cada petición. La solución es la misma que en las páginas: meter la consulta en una función con 'use cache'.

// lib/catalogo.ts
import 'server-only'
import { cacheLife, cacheTag } from 'next/cache'
import { API, type Lista, type Producto } from './api'

export async function obtenerCatalogo() {
  'use cache'
  cacheLife('hours')
  cacheTag('productos')

  const res = await fetch(`${API}/productos?tamano=100&disponibles=true`)
  const lista: Lista<Producto> = await res.json()
  return {
    generado: new Date().toISOString(),
    productos: lista.contenido.map((p) => ({
      id: p.productoId,
      nombre: p.nombre,
      precio: p.precio,
      categoria: p.categoria,
    })),
  }
}
// app/api/catalogo/route.ts
import { obtenerCatalogo } from '@/lib/catalogo'

export async function GET() {
  const catalogo = await obtenerCatalogo()
  return Response.json(catalogo)
}

El build lo marca ○ con Revalidate 1h y Expire 1d, los valores del perfil 'hours'. La respuesta lleva la hora del build en generado y estas cabeceras:

200 x-nextjs-cache: HIT | cache-control: s-maxage=3600, stale-while-revalidate=82800
{"generado":"2026-11-02T11:50:45.438Z","productos":[{"id":1,"nombre":"Portátil UltraBook 14","precio":899,"categoria":"Portátiles"}, ...

stale-while-revalidate=82800 son los 23 h que faltan desde la primera hora hasta el día de Expire: en ese intervalo se puede servir la versión vieja mientras se regenera.

No pongas 'use cache' dentro del handler

Parece más corto escribir la directiva directamente en el GET:

// MAL
export async function GET() {
  'use cache'
  return Response.json({ hora: Date.now() })
}

Compila, pero next build falla al prerenderizar:

Error occurred prerendering page "/api/cache-mal". Read more: https://nextjs.org/docs/messages/prerender-error
Error: Only plain objects, and a few built-ins, can be passed to Client Components from Server Components. Classes or null prototypes are not supported.
  {}

El mensaje despista (habla de Client Components), pero la causa es clara si recuerdas el módulo 09: lo que devuelve una función con 'use cache' se serializa para guardarlo, y un objeto Response no es serializable. La documentación lo dice sin rodeos: 'use cache' no se puede usar directamente en el cuerpo de un Route Handler; sácalo a una función auxiliar que devuelva datos, y construye el Response fuera.

8.5. generateStaticParams en un handler dinámico

Un handler con segmento dinámico también puede generar algunas respuestas en el build:

// app/api/catalogo/[id]/route.ts
import { obtenerProducto } from '@/lib/catalogo'
import { error } from '@/lib/api'

export async function generateStaticParams() {
  return [{ id: '1' }, { id: '2' }, { id: '3' }]
}

export async function GET(_request: Request, ctx: RouteContext<'/api/catalogo/[id]'>) {
  const { id } = await ctx.params
  const producto = await obtenerProducto(id)
  if (!producto) return error(404, `No existe el producto ${id}`)
  return Response.json(producto)
}
// lib/catalogo.ts (continuación)
export async function obtenerProducto(id: string) {
  'use cache'
  cacheLife('hours')
  cacheTag('productos', `producto-${id}`)

  const res = await fetch(`${API}/productos/${id}`)
  if (!res.ok) return null
  const p: Producto = await res.json()
  return { id: p.productoId, nombre: p.nombre, precio: p.precio, generado: new Date().toISOString() }
}

Los productos 1, 2 y 3 salen ● en el build. Los demás se generan la primera vez que alguien los pide y desde entonces se sirven de la caché:

GET /api/catalogo/2    200 x-nextjs-cache: HIT    {"id":2,...,"generado":"2026-11-02T11:50:45.350Z"}
GET /api/catalogo/7    200 x-nextjs-cache: MISS   {"id":7,"nombre":"Teclado mecánico",...,"generado":"2026-11-02T11:52:30.299Z"}
GET /api/catalogo/999  404 x-nextjs-cache: MISS   {"status":404,"mensaje":"No existe el producto 999"}

8.6. Revalidar desde un Route Handler

¿Cómo se entera la caché de que un producto ha cambiado? Lo habitual es que el sistema que cambia los datos (un panel de administración, otra aplicación, un webhook) avise a una URL tuya, y que esa URL llame a revalidateTag. Un Route Handler es el sitio perfecto:

// app/api/revalidar/route.ts
import { revalidateTag } from 'next/cache'
import type { NextRequest } from 'next/server'
import { error } from '@/lib/api'

// POST /api/revalidar  con cabecera  x-secreto: ...  y cuerpo { "etiqueta": "productos" }
export async function POST(request: NextRequest) {
  if (request.headers.get('x-secreto') !== process.env.SECRETO_REVALIDACION) {
    return error(401, 'Secreto incorrecto')
  }
  const { etiqueta, inmediato } = (await request.json()) as { etiqueta?: string; inmediato?: boolean }
  if (!etiqueta) return error(422, 'Falta la etiqueta')

  // 'max': se sigue sirviendo lo viejo mientras se regenera en segundo plano
  // { expire: 0 }: lo viejo se descarta; la siguiente petición espera al dato nuevo
  revalidateTag(etiqueta, inmediato ? { expire: 0 } : 'max')
  return Response.json({ revalidada: etiqueta, inmediato: Boolean(inmediato), cuando: new Date().toISOString() })
}

El secreto evita que cualquiera vacíe tu caché a base de peticiones. Secuencia real con 'max', el valor recomendado:

POST /api/revalidar (sin secreto)          401 {"status":401,"mensaje":"Secreto incorrecto"}
POST /api/revalidar {"etiqueta":"productos"}  200 {"revalidada":"productos",...}
GET /api/catalogo   x-nextjs-cache: STALE  {"generado":"2026-11-02T11:50:45.438Z",...   <- la versión vieja
(1,5 s después)
GET /api/catalogo   x-nextjs-cache: HIT    {"generado":"2026-11-02T11:52:30.672Z",...   <- ya regenerada

Y con { expire: 0 } sobre la etiqueta categorias:

POST /api/revalidar {"etiqueta":"categorias","inmediato":true}
GET /api/categorias  x-nextjs-cache: MISS  {"generado":"2026-11-02T12:06:44.835Z",...   <- espera al dato nuevo
GET /api/categorias  x-nextjs-cache: HIT   {"generado":"2026-11-02T12:06:44.835Z",...
Llamada Primera petición después Cuándo usarla
revalidateTag(tag, 'max') Recibe la versión vieja (STALE) y dispara la regeneración Catálogos, blogs: un momento de retraso no importa
revalidateTag(tag, { expire: 0 }) Espera a la versión nueva (MISS) Quien avisa necesita que el dato viejo desaparezca ya
updateTag(tag) Como expire: 0 y además el usuario ve su cambio Solo en Server Actions (módulo 10); no existe en Route Handlers

⚠️ Importante: En Next.js 16 revalidateTag exige el segundo argumento. Sin él, la documentación lo da por obsoleto y se comporta como { expire: 0 }. updateTag no sirve en un Route Handler: está pensado para que el usuario que hizo el cambio en una Server Action vea su propio cambio al instante.

En Next.js 15

En Next.js 15 (sin cacheComponents), un /api/hola como el del principio, que no lee nada de la petición, no es estático. Este es el build real con Next.js 15.5.28:

Route (app)                         Size  First Load JS  Revalidate  Expire
├ ○ /api/catalogo                    0 B            0 B          1h      1y
├ ● /api/catalogo/[id]               0 B            0 B          1h      1y
├   ├ /api/catalogo/1                                            1h      1y
├   ├ /api/catalogo/2                                            1h      1y
├   └ /api/catalogo/3                                            1h      1y
├ ƒ /api/hola                        0 B            0 B
├ ○ /api/hola-estatico               0 B            0 B
└ ƒ /api/revalidar                   0 B            0 B

Desde Next.js 15, los GET son dinámicos por defecto (en la 14 era al revés). Para cachear, se usa la configuración de segmento y las opciones de fetch que viste en el módulo 09:

// Next.js 15: app/api/hola-estatico/route.ts
export const dynamic = 'force-static'

export async function GET() {
  return Response.json({ mensaje: 'Hola', generado: new Date().toISOString() })
}
// Next.js 15: app/api/catalogo/route.ts
const API = process.env.EMPRESA_API ?? 'http://localhost:3000/api'

export const revalidate = 3600 // segundos

export async function GET() {
  const res = await fetch(`${API}/productos?tamano=100&disponibles=true`, {
    next: { tags: ['productos'] },
  })
  const { contenido } = await res.json()
  return Response.json({ generado: new Date().toISOString(), total: contenido.length })
}
// Next.js 15: app/api/catalogo/[id]/route.ts
import { unstable_cache } from 'next/cache'

const API = process.env.EMPRESA_API ?? 'http://localhost:3000/api'

const obtenerProducto = unstable_cache(
  async (id: string) => {
    const res = await fetch(`${API}/productos/${id}`)
    if (!res.ok) return null
    const p = await res.json()
    return { id: p.productoId, nombre: p.nombre, generado: new Date().toISOString() }
  },
  ['producto'],
  { revalidate: 3600, tags: ['productos'] },
)

export async function generateStaticParams() {
  return [{ id: '1' }, { id: '2' }, { id: '3' }]
}

export async function GET(_request: Request, ctx: RouteContext<'/api/catalogo/[id]'>) {
  const { id } = await ctx.params
  const producto = await obtenerProducto(id)
  if (!producto) return Response.json({ mensaje: 'No existe' }, { status: 404 })
  return Response.json(producto)
}

Y en el handler de revalidación, revalidateTag(etiqueta) con un solo argumento. En 15 eso borra la entrada al momento: la siguiente petición fue MISS con datos nuevos, como { expire: 0 } en 16:

POST /api/revalidar {"etiqueta":"productos"}   {"revalidada":"productos"}
GET /api/catalogo   x-nextjs-cache: MISS   {"generado":"2026-11-02T12:03:23.844Z","total":18}
GET /api/catalogo   x-nextjs-cache: HIT    {"generado":"2026-11-02T12:03:23.844Z","total":18}

9. Respuestas que no son JSON

Un Route Handler puede devolver cualquier cosa que quepa en una respuesta HTTP. Lo único que tienes que hacer es poner bien el Content-Type, para que el cliente sepa qué ha recibido.

9.1. Descargar un CSV

Contabilidad quiere los pedidos en una hoja de cálculo. Un CSV es texto, así que basta con construir la cadena:

// app/pedidos.csv/route.ts
import { connection } from 'next/server'
import { API } from '@/lib/api'

type Pedido = { pedidoId: number; fechaPedido: string; cliente: string; estado: string; total: number }

function celda(valor: string | number) {
  const texto = String(valor)
  return /[";\n]/.test(texto) ? `"${texto.replaceAll('"', '""')}"` : texto
}

export async function GET() {
  await connection()
  const res = await fetch(`${API}/pedidos?tamano=100`)
  const { contenido } = (await res.json()) as { contenido: Pedido[] }

  const filas = [
    ['pedido', 'fecha', 'cliente', 'estado', 'total'],
    ...contenido.map((p) => [p.pedidoId, p.fechaPedido, p.cliente, p.estado, p.total.toFixed(2).replace('.', ',')]),
  ]
  // BOM + punto y coma: así Excel en español abre bien tildes y columnas
  const csv = '' + filas.map((f) => f.map(celda).join(';')).join('\r\n')

  return new Response(csv, {
    headers: {
      'Content-Type': 'text/csv; charset=utf-8',
      'Content-Disposition': 'attachment; filename="pedidos.csv"',
    },
  })
}
200 content-type: text/csv; charset=utf-8 | content-disposition: attachment; filename="pedidos.csv"
pedido;fecha;cliente;estado;total
5001;2025-01-15;Talleres Martín S.L.;ENTREGADO;1802,90
5002;2025-01-28;Construcciones Levante S.A.;ENTREGADO;7402,50
5003;2025-02-03;María José Herrera;ENTREGADO;558,90
...

Las decisiones que hay detrás:

  • Content-Disposition: attachment hace que el navegador descargue el fichero con ese nombre en lugar de mostrarlo. Un enlace normal <a href="/pedidos.csv">Descargar</a> basta para usarlo.
  • Punto y coma y coma decimal. Excel con configuración regional española usa ; como separador de columnas y , para los decimales. Con comas, todo acabaría en la columna A.
  • El BOM () es un carácter invisible al principio que le indica a Excel que el fichero está en UTF-8. Sin él, “Martín” se ve como “Martín”.
  • celda() escapa los valores que contienen ;, comillas o saltos de línea, como manda el formato CSV.
  • connection() porque queremos los pedidos de este momento, no los del build. Sin él, el fetch sin caché también haría el handler dinámico, pero así queda explícito.

9.2. Otros formatos

Qué Content-Type Ejemplo
RSS / XML application/rss+xml; charset=utf-8 Ejercicio 6
Texto plano text/plain; charset=utf-8 Sección 10.1
JSON por líneas application/x-ndjson Ejercicio 7
Eventos del servidor text/event-stream Sección 10.3
Imagen o PDF image/png, application/pdf new Response(bytes, { headers }) con un Buffer o Uint8Array

💡 Consejo: Para sitemap.xml, robots.txt, manifest, iconos e imágenes Open Graph no escribas un Route Handler: Next tiene ficheros especiales que ya generan el formato correcto (los viste en el módulo 07). Por dentro también son Route Handlers, pero con la plantilla hecha.


10. Streaming: responder por partes

Normalmente un handler calcula toda la respuesta y la envía de golpe. Pero a veces conviene empezar a enviar antes de terminar:

  • Un informe que tarda varios segundos: el usuario ve las primeras líneas enseguida.
  • Una exportación grande: no hace falta tener todo en memoria.
  • Avisos en tiempo real: el estado de un pedido, el progreso de una tarea.
  • Las respuestas de un modelo de lenguaje, que llegan palabra a palabra.

La herramienta es el ReadableStream del estándar web: un objeto al que vas añadiendo trozos (enqueue) hasta que lo cierras (close). Si lo pasas como cuerpo de un Response, Next envía cada trozo en cuanto existe, con Transfer-Encoding: chunked.

10.1. Un informe línea a línea

// app/api/informe/route.ts
import { connection } from 'next/server'

const esperar = (ms: number) => new Promise((r) => setTimeout(r, ms))

// Envía un informe línea a línea, a medida que se "calcula"
export async function GET(request: Request) {
  await connection()
  const codificador = new TextEncoder()
  const departamentos = ['DIRECCIÓN', 'VENTAS', 'MARKETING', 'INFORMÁTICA']

  const flujo = new ReadableStream<Uint8Array>({
    async start(controlador) {
      for (const d of departamentos) {
        if (request.signal.aborted) break // el cliente se fue: dejamos de trabajar
        await esperar(500)
        controlador.enqueue(codificador.encode(`${new Date().toISOString().slice(11, 23)} ${d} calculado\n`))
      }
      controlador.close()
    },
  })

  return new Response(flujo, {
    headers: { 'Content-Type': 'text/plain; charset=utf-8' },
  })
}

Para comprobar que de verdad llega por partes, el laboratorio lee la respuesta con curl -N (sin búfer) y apunta la hora local a la que llega cada línea. La primera columna es la hora de llegada (local, UTC+1) y la segunda, la que escribió el servidor (UTC):

[llega 12:52:48.415] 11:52:48.143 DIRECCIÓN calculado
[llega 12:52:48.878] 11:52:48.649 VENTAS calculado
[llega 12:52:49.396] 11:52:49.158 MARKETING calculado
[llega 12:52:49.902] 11:52:49.668 INFORMÁTICA calculado

Cada línea llega medio segundo después de la anterior, no las cuatro juntas al final.

Tres piezas que hay que entender:

  • TextEncoder convierte texto en bytes (Uint8Array). Un stream de respuesta transporta bytes, no cadenas.
  • request.signal es un AbortSignal que se activa si el cliente cierra la conexión. Comprobarlo evita seguir calculando (y gastando) para nadie.
  • connection(): un stream depende del momento de la petición. Sin dinamismo, Next intentaría ejecutarlo en el build.

⚠️ Importante: Entre tu servidor y el usuario puede haber intermediarios (un proxy inverso como nginx, una CDN, la compresión) que acumulan la respuesta y la entregan entera al final, y el streaming deja de notarse. Si en producción todo llega de golpe, revisa su configuración. Por eso los ejemplos de SSE añaden Cache-Control: no-cache, no-transform.

10.2. Un generador como fuente

Si te resulta más natural, puedes escribir la lógica como un generador asíncrono (async function*), que va “soltando” trozos con yield, y convertirlo en stream con una pequeña función. Es la técnica que muestra la documentación de Next:

// app/api/generador/route.ts
import { connection } from 'next/server'

const esperar = (ms: number) => new Promise((r) => setTimeout(r, ms))

function aStream(iterador: AsyncIterator<Uint8Array>) {
  return new ReadableStream<Uint8Array>({
    async pull(controlador) {
      const { value, done } = await iterador.next()
      if (done) controlador.close()
      else controlador.enqueue(value)
    },
  })
}

async function* lineas() {
  const codificador = new TextEncoder()
  for (const d of ['DIRECCION', 'VENTAS', 'MARKETING']) {
    await esperar(300)
    yield codificador.encode(`${d}\n`)
  }
}

export async function GET() {
  await connection()
  return new Response(aStream(lineas()), { headers: { 'Content-Type': 'text/plain; charset=utf-8' } })
}

La diferencia con la sección anterior es quién manda. Con start() el servidor empuja los trozos a su ritmo. Con pull() el stream pide el siguiente trozo cuando el cliente está listo para recibirlo; si el cliente va lento, el generador se queda esperando en el yield y no se acumula nada en memoria.

⚠️ Importante: Las versiones actuales de Node.js tienen el atajo ReadableStream.from(lineas()), que hace lo mismo que aStream. Funciona al ejecutarlo, pero en un proyecto Next no compila: los tipos de ReadableStream que usa TypeScript son los del navegador (lib.dom), que no lo incluyen. tsc da Property 'from' does not exist on type .... Usa la función aStream de arriba.

Si los generadores no te suenan, el Curso de JavaScript los explica en el tema de iteradores.

10.3. Server-Sent Events: avisos en tiempo real

Server-Sent Events (SSE) es un formato estándar para que el servidor envíe avisos al navegador por una conexión que se mantiene abierta. Es mucho más sencillo que WebSocket, y para el caso típico (el servidor avisa, el navegador escucha) basta. El formato es texto con líneas event: y data:, y una línea en blanco al final de cada mensaje:

event: estado
data: {"pedido":5003,"estado":"PENDIENTE","hora":"11:52:50"}

El handler simula los cambios de estado de un pedido:

// app/api/pedidos/[id]/eventos/route.ts
import { connection } from 'next/server'

const ESTADOS = ['PENDIENTE', 'PREPARANDO', 'ENVIADO', 'ENTREGADO'] as const
const esperar = (ms: number) => new Promise((r) => setTimeout(r, ms))

// Server-Sent Events: el navegador lo consume con EventSource
export async function GET(request: Request, ctx: RouteContext<'/api/pedidos/[id]/eventos'>) {
  await connection()
  const { id } = await ctx.params
  const codificador = new TextEncoder()

  const flujo = new ReadableStream<Uint8Array>({
    async start(controlador) {
      const enviar = (evento: string, datos: unknown) =>
        controlador.enqueue(codificador.encode(`event: ${evento}\ndata: ${JSON.stringify(datos)}\n\n`))

      for (const estado of ESTADOS) {
        if (request.signal.aborted) return
        enviar('estado', { pedido: Number(id), estado, hora: new Date().toISOString().slice(11, 19) })
        await esperar(800)
      }
      enviar('fin', { pedido: Number(id) })
      controlador.close()
    },
  })

  return new Response(flujo, {
    headers: {
      'Content-Type': 'text/event-stream; charset=utf-8',
      'Cache-Control': 'no-cache, no-transform',
      Connection: 'keep-alive',
    },
  })
}

Así llega con curl -N -i (recortado):

HTTP/1.1 200 OK
cache-control: no-cache, no-transform
connection: keep-alive
content-type: text/event-stream; charset=utf-8
Transfer-Encoding: chunked

event: estado
data: {"pedido":5003,"estado":"PENDIENTE","hora":"11:52:50"}

event: estado
data: {"pedido":5003,"estado":"PREPARANDO","hora":"11:52:51"}
...

En el navegador, la API EventSource hace todo el trabajo de leer ese formato:

// app/seguimiento/seguimiento-pedido.tsx
'use client'

import { useEffect, useState } from 'react'

type Paso = { estado: string; hora: string }

export function SeguimientoPedido({ pedidoId }: { pedidoId: number }) {
  const [pasos, setPasos] = useState<Paso[]>([])
  const [terminado, setTerminado] = useState(false)

  useEffect(() => {
    const fuente = new EventSource(`/api/pedidos/${pedidoId}/eventos`)
    fuente.addEventListener('estado', (e) => {
      const paso = JSON.parse((e as MessageEvent).data) as Paso
      setPasos((anteriores) => [...anteriores, paso])
    })
    fuente.addEventListener('fin', () => {
      setTerminado(true)
      fuente.close() // si no lo cierras, EventSource vuelve a conectar solo
    })
    return () => fuente.close()
  }, [pedidoId])

  return (
    <div>
      <ol className="space-y-2">
        {pasos.map((p) => (
          <li key={p.estado} className="flex items-center gap-3 rounded bg-indigo-50 px-3 py-2">
            <span className="h-3 w-3 rounded-full bg-indigo-600" />
            <span className="font-medium">{p.estado}</span>
            <span className="ml-auto font-mono text-sm text-slate-500">{p.hora}</span>
          </li>
        ))}
      </ol>
      <p className="mt-4 text-sm text-slate-600">
        {terminado ? 'Pedido entregado. Conexión cerrada.' : 'Esperando novedades...'}
      </p>
    </div>
  )
}
// app/seguimiento/page.tsx
import { SeguimientoPedido } from './seguimiento-pedido'

export default function PaginaSeguimiento() {
  return (
    <main className="mx-auto max-w-xl p-8">
      <h1 className="mb-4 text-2xl font-bold text-indigo-900">Seguimiento del pedido 5003</h1>
      <SeguimientoPedido pedidoId={5003} />
    </main>
  )
}

Página Seguimiento del pedido 5003 con cuatro filas, PENDIENTE, PREPARANDO, ENVIADO y ENTREGADO, cada una con su hora, y debajo el texto Pedido entregado. Conexión cerrada

Figura 6. La página de seguimiento cuando ha llegado el último evento. Las filas aparecen una a una, unos 800 ms después de la anterior.

Lo que debes saber de EventSource:

  • addEventListener('estado', ...) escucha los mensajes con event: estado. Los que no llevan event: llegan a onmessage.
  • Reconecta solo. Si el servidor cierra la conexión, EventSource vuelve a abrirla al cabo de unos segundos, y el pedido empezaría otra vez desde PENDIENTE. Por eso enviamos un evento fin y el cliente llama a close().
  • Solo admite GET y no permite poner cabeceras como Authorization. Para identificar al usuario se usa la cookie de sesión, que el navegador envía sola al mismo origen.

📘 Concepto: SSE, WebSocket y polling. Con polling el cliente pregunta cada X segundos; es lo más simple y lo que más peticiones gasta. Con SSE el servidor avisa por una conexión abierta, en un solo sentido; encaja perfectamente con un Route Handler. WebSocket es bidireccional (chat, juegos), pero necesita un servidor que mantenga conexiones permanentes, y los Route Handlers no lo ofrecen: se monta con un servidor aparte o un servicio externo.

⚠️ Importante: Una conexión SSE ocupa el servidor mientras está abierta. En un servidor propio con next start no es problema para unas decenas de usuarios. En plataformas serverless cada función tiene un tiempo máximo de ejecución, así que una conexión no puede durar indefinidamente; consulta los límites de tu proveedor (lo verás en el módulo 23).


11. CORS: abrir tu API a otras webs

11.1. El problema

Imagina que el equipo de marketing tiene una web hecha con Vite en http://localhost:5173 (en producción, https://tienda.empresa.es) y quiere mostrar los productos destacados pidiéndoselos con fetch a tu aplicación Next. El navegador lo impide.

Es la política del mismo origen: por seguridad, el JavaScript de una página solo puede leer respuestas de su propio origen (protocolo + dominio + puerto). http://localhost:5173 y http://localhost:3001 son orígenes distintos porque cambia el puerto.

CORS (Cross-Origin Resource Sharing) es el mecanismo para que tu servidor diga: “a este otro origen le dejo leer mis respuestas”. Lo hace con cabeceras Access-Control-Allow-*.

📘 Concepto: CORS lo aplica el navegador, no el servidor. Sin las cabeceras, tu handler se ejecuta igual y responde; es el navegador el que esconde la respuesta al JavaScript de la otra página. Por eso curl, Postman u otro servidor nunca tienen “problemas de CORS”, y por eso CORS no es una medida de seguridad para tu API: protege a los usuarios del navegador, no a tu servidor. Para proteger tu API necesitas autenticación.

11.2. El preflight

Para peticiones “simples” (un GET sin cabeceras especiales) el navegador hace la petición y luego mira las cabeceras. Pero si la petición lleva Authorization, un Content-Type: application/json o un método como PUT o DELETE, antes envía una petición de comprobación previa (preflight) con el método OPTIONS, para preguntar si está permitido.

Diagrama de secuencia entre el navegador en localhost:5173 y Next.js: 1, OPTIONS con Origin y Access-Control-Request-Method; 2, respuesta 204 con Access-Control-Allow-Origin, Allow-Methods, Allow-Headers y Max-Age; 3, el GET de verdad; 4, 200 con el JSON y Allow-Origin otra vez. Nota: si falta Allow-Origin, el servidor responde pero el navegador esconde la respuesta

Figura 7. Una petición con CORS y *preflight. Las dos respuestas deben llevar Access-Control-Allow-Origin.*

11.3. Un handler con lista de orígenes permitidos

La documentación de Next enseña el ejemplo más corto, 'Access-Control-Allow-Origin': '*', que permite cualquier origen. Sirve para datos totalmente públicos, pero * no se puede combinar con credenciales (cookies), y casi siempre quieres decidir quién entra. Esta versión usa una lista blanca:

// app/api/publico/productos/route.ts
import type { NextRequest } from 'next/server'
import { obtenerCatalogo } from '@/lib/catalogo'

// Orígenes que pueden llamar a esta API desde el navegador
const PERMITIDOS = ['http://localhost:5173', 'https://tienda.empresa.es']

function cabecerasCors(origen: string | null): Record<string, string> {
  if (!origen || !PERMITIDOS.includes(origen)) return { Vary: 'Origin' }
  return {
    'Access-Control-Allow-Origin': origen,
    'Access-Control-Allow-Methods': 'GET, OPTIONS',
    'Access-Control-Allow-Headers': 'Content-Type, Authorization',
    'Access-Control-Max-Age': '600',
    Vary: 'Origin',
  }
}

export async function OPTIONS(request: NextRequest) {
  return new Response(null, { status: 204, headers: cabecerasCors(request.headers.get('origin')) })
}

export async function GET(request: NextRequest) {
  const { productos } = await obtenerCatalogo()
  return Response.json(productos.slice(0, 3), { headers: cabecerasCors(request.headers.get('origin')) })
}

Simulamos al navegador con la cabecera Origin. Resultados reales:

OPTIONS  Origin: http://localhost:5173
204 access-control-allow-origin: http://localhost:5173 | access-control-allow-methods: GET, OPTIONS
    vary: rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Origin

GET  Origin: http://localhost:5173
200 access-control-allow-origin: http://localhost:5173
[{"id":1,"nombre":"Portátil UltraBook 14","precio":899,"categoria":"Portátiles"}, ...]

GET  Origin: https://malo.example
200 access-control-allow-origin: null
[{"id":1,"nombre":"Portátil UltraBook 14","precio":899,"categoria":"Portátiles"}, ...]

(null es lo que imprime el script de pruebas cuando la cabecera no existe.) Fíjate en el último caso: el servidor respondió con los datos. Para https://malo.example no hay Access-Control-Allow-Origin, así que un navegador en esa web no dejará que su JavaScript los lea, pero un curl sí los ve. Es justo lo que decía la caja anterior.

Detalles de cada cabecera:

  • Access-Control-Allow-Origin devuelve el origen exacto que pidió, si está en la lista.
  • Vary: Origin avisa a las cachés intermedias de que la respuesta cambia según el origen. Sin ella, una CDN podría guardar la respuesta con el Allow-Origin de un origen y dársela a otro. Next añade sus propias entradas a Vary y respeta la tuya.
  • Access-Control-Max-Age: 600 permite al navegador recordar el preflight 10 minutos en lugar de repetirlo antes de cada petición.
  • El handler es dinámico (ƒ en el build) porque lee request.headers. El dato pesado, el catálogo, sigue saliendo de la caché de 'use cache'.

💡 Consejo: Si tienes muchos handlers que necesitan CORS, repetir la función en cada fichero es incómodo. Puedes ponerlo en un solo sitio con proxy.ts o con headers en next.config.ts, que se ven en el módulo 13.

⚠️ Importante: Recuerda la sección 3.2: si el handler fuera estático y no exportara OPTIONS, el preflight recibiría un 405 sin cabeceras CORS y el navegador bloquearía la petición. Si una API pública necesita preflight, exporta OPTIONS siempre.


12. Webhooks: cuando otro servicio te llama a ti

12.1. Qué es un webhook

Un webhook es una petición que un servicio externo hace a tu servidor para avisarte de algo: “el pago del pedido 5003 se ha completado”, “ha llegado un correo”, “se ha subido código al repositorio”. En lugar de que tú preguntes cada minuto, el servicio te llama cuando pasa.

Para recibirlo solo necesitas una URL pública que acepte POST: un Route Handler. Lo difícil no es recibirlo, sino fiarte de él. La URL es pública, y cualquiera puede enviar {"tipo":"pago.completado","pedidoId":5003} sin haber pagado nada.

12.2. Firma HMAC

Los proveedores serios (Stripe, GitHub, pasarelas de pago…) resuelven esto firmando cada envío. Al darte de alta te entregan un secreto que conocéis los dos. En cada envío calculan un HMAC (una huella criptográfica) del cuerpo con ese secreto y te la mandan en una cabecera. Tú calculas lo mismo con tu copia del secreto: si coincide, el mensaje viene de quien tiene el secreto y no se ha modificado por el camino.

Diagrama: la pasarela de pagos calcula firma = HMAC-SHA256(secreto, marca + punto + cuerpo) y envía el POST con las cabeceras x-pagos-marca y x-pagos-firma; tu route.ts lee el cuerpo como texto, comprueba la marca de tiempo, recalcula el HMAC, lo compara con timingSafeEqual, mira si el evento ya se procesó, responde 200 y hace el trabajo lento con after

Figura 8. Cómo se comprueba un *webhook firmado. El secreto nunca viaja en la petición.*

Cada proveedor tiene sus nombres de cabeceras y su formato exacto (y muchos dan una biblioteca que lo comprueba por ti), pero el esquema es casi siempre este. Lo implementamos para una pasarela de pagos imaginaria:

// app/api/webhooks/pagos/route.ts
import { createHmac, timingSafeEqual } from 'node:crypto'
import { after } from 'next/server'

const TOLERANCIA_S = 300 // 5 minutos

function firmaValida(cuerpo: string, marca: string, firma: string, secreto: string) {
  const esperada = createHmac('sha256', secreto).update(`${marca}.${cuerpo}`).digest('hex')
  const a = Buffer.from(esperada, 'hex')
  const b = Buffer.from(firma, 'hex')
  return a.length === b.length && timingSafeEqual(a, b)
}

const procesados = new Set<string>() // en producción: una tabla en la base de datos

export async function POST(request: Request) {
  const secreto = process.env.SECRETO_WEBHOOK
  if (!secreto) return new Response('Webhook sin configurar', { status: 500 })

  const marca = request.headers.get('x-pagos-marca') ?? ''
  const firma = request.headers.get('x-pagos-firma') ?? ''
  const cuerpo = await request.text() // ¡texto crudo, no json()!

  const edad = Math.abs(Date.now() / 1000 - Number(marca))
  if (!marca || Number.isNaN(edad) || edad > TOLERANCIA_S) {
    return new Response('Marca de tiempo fuera de plazo', { status: 400 })
  }
  if (!firmaValida(cuerpo, marca, firma, secreto)) {
    return new Response('Firma no válida', { status: 401 })
  }

  const evento = JSON.parse(cuerpo) as { id: string; tipo: string; pedidoId: number }
  if (procesados.has(evento.id)) {
    return Response.json({ recibido: true, duplicado: true })
  }
  procesados.add(evento.id)

  after(() => {
    // Trabajo lento después de responder: correos, actualizar el pedido...
    console.log(`[webhook] ${evento.tipo} del pedido ${evento.pedidoId} procesado`)
  })

  return Response.json({ recibido: true })
}

Paso a paso, y por qué:

  1. request.text(), no request.json(). La firma se calculó sobre los bytes exactos que se enviaron. Si analizas el JSON y lo vuelves a convertir en texto, un espacio o el orden de las claves pueden cambiar, y la firma ya no coincide. Primero compruebas la firma con el texto crudo y después haces JSON.parse.
  2. La marca de tiempo entra en la firma. Si alguien captura una petición válida y la reenvía una hora después (un ataque de repetición, replay), la marca está fuera de plazo y se rechaza. Sin marca, una petición capturada valdría para siempre.
  3. timingSafeEqual compara en tiempo constante. Una comparación normal (===) termina en cuanto encuentra un carácter distinto, y midiendo tiempos con mucha paciencia un atacante podría ir adivinando la firma carácter a carácter. Exige que los dos Buffer midan lo mismo, por eso se comprueba la longitud antes.
  4. Idempotencia. Los proveedores reintentan si no reciben un 2xx a tiempo, así que el mismo evento puede llegar dos veces. Guardar los id procesados evita, por ejemplo, enviar dos correos de confirmación. Un Set en memoria se pierde al reiniciar y no se comparte entre servidores; en un proyecto real va a la base de datos.
  5. Responder deprisa con after(). after (de next/server) programa una función para después de enviar la respuesta. El proveedor recibe su 200 en milisegundos, y el trabajo lento (correos, actualizar el pedido) ocurre luego. Si el proveedor espera demasiado, da el envío por fallido y lo repite.

📘 Concepto: node:crypto es el módulo de criptografía de Node.js. Los Route Handlers se ejecutan en Node.js por defecto, así que puedes importarlo sin más. Next.js también tiene un runtime edge con menos APIs de Node; lo veremos al hablar de proxy.ts en el módulo 13.

12.3. Probarlo sin la pasarela

Para probar un webhook necesitas a alguien que lo envíe bien firmado. Este script de Node hace de pasarela:

// enviar-webhook.mjs
// Simula a la pasarela de pagos: node enviar-webhook.mjs [url] [--vieja] [--mala]
import { createHmac } from 'node:crypto'

const url = process.argv[2] ?? 'http://localhost:3001/api/webhooks/pagos'
const secreto = process.env.SECRETO_WEBHOOK ?? 'whsec_curso_next'
const vieja = process.argv.includes('--vieja')
const mala = process.argv.includes('--mala')

const cuerpo = JSON.stringify({ id: 'evt_001', tipo: 'pago.completado', pedidoId: 5003, importe: 1802.9 })
const marca = String(Math.floor(Date.now() / 1000) - (vieja ? 3600 : 0))
let firma = createHmac('sha256', secreto).update(`${marca}.${cuerpo}`).digest('hex')
if (mala) firma = firma.replace(/^./, (c) => (c === 'a' ? 'b' : 'a'))

const res = await fetch(url, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json', 'x-pagos-marca': marca, 'x-pagos-firma': firma },
  body: cuerpo,
})
console.log(res.status, await res.text())

Con SECRETO_WEBHOOK=whsec_curso_next en el .env.local de Next, las cinco pruebas dan:

$ node enviar-webhook.mjs                                  200 {"recibido":true}
$ node enviar-webhook.mjs                                  200 {"recibido":true,"duplicado":true}
$ node enviar-webhook.mjs --vieja                          400 Marca de tiempo fuera de plazo
$ node enviar-webhook.mjs --mala                           401 Firma no válida
$ SECRETO_WEBHOOK=otro node enviar-webhook.mjs             401 Firma no válida

Y en la consola del servidor aparece, una sola vez y después de la respuesta, la línea de after:

[webhook] pago.completado del pedido 5003 procesado

💡 Consejo: Para recibir webhooks reales en tu ordenador, el proveedor necesita una URL pública. Muchos dan una herramienta de línea de órdenes que los reenvía a localhost; si no, hay servicios de túnel que publican temporalmente tu puerto local.


13. ¿Route Handler o Server Action?

Con lo que sabes ya, puedes hacer casi todo de dos maneras. Esta tabla te ayuda a decidir:

Necesidad Mejor opción Por qué
Mostrar datos en una página Server Component que consulta directamente Sin petición HTTP extra; el dato ya está en el servidor
Guardar un formulario de tu web Server Action Funciona sin JavaScript, revalida y redirige integrado (módulo 10)
Búsqueda en vivo desde un componente cliente Route Handler GET (o una Server Action) Un GET se puede cachear y compartir por URL; una Action es siempre POST y se ejecuta de una en una
App móvil, otra web, un socio Route Handler Necesitan una URL estable con contrato HTTP
Webhook de un servicio externo Route Handler El servicio hace un POST a una URL que tú le das
Descarga (CSV, PDF), RSS Route Handler Formato y cabeceras propios
Avisos en tiempo real (SSE) Route Handler Necesitas una respuesta en stream de larga duración

13.1. El error de llamar a tu propia API desde el servidor

Un error muy común al empezar es este:

// MAL: un Server Component que pide datos a un Route Handler de la misma aplicación
export default async function Pagina() {
  const res = await fetch('http://localhost:3001/api/empleados')
  const datos = await res.json()
  // ...
}

Funciona en desarrollo, pero tiene varios problemas:

  • Es un viaje de ida y vuelta inútil. El Server Component ya se ejecuta en el servidor: puede llamar a la misma función que usa el handler. Hacer una petición HTTP a ti mismo añade latencia y consume otra conexión.
  • Hay que escribir la URL absoluta, y en el build puede que no haya ningún servidor escuchando en esa dirección.
  • Pierdes el contexto de la petición: las cookies del usuario no viajan solas en ese fetch interno.

La solución es compartir el código, no la URL. Pon la lógica en una función (obtenerCatalogo() de lib/catalogo.ts) y llámala desde la página y desde el handler. Es lo que hemos hecho con el catálogo: /api/catalogo, /api/publico/productos y /novedades.xml usan la misma función con 'use cache', y una página podría usarla igual.


14. Seguridad básica de un Route Handler

Un Route Handler es una puerta abierta a Internet. Lista de comprobación mínima, que ampliaremos en el módulo 17 (autenticación) y el módulo 22 (seguridad):

  • Autentica y autoriza en cada método. Que la interfaz no tenga un botón “Borrar” no impide que alguien envíe un DELETE con curl. Comprueba la sesión y el rol dentro del handler, aunque proxy.ts haga una primera comprobación.
  • Valida todo lo que entra: parámetros de la URL, cabeceras y cuerpo. Con Zod y respuestas 400/422 claras.
  • Devuelve solo lo necesario. Selecciona los campos (como hicimos con los empleados) en lugar de reenviar el objeto entero de la base de datos.
  • No filtres detalles internos en los errores. Un 500 con la traza del error o el mensaje de la base de datos ayuda al atacante. Registra el detalle en el servidor y devuelve un mensaje genérico.
  • Protege las URL “de servicio” (/api/revalidar, webhooks) con un secreto o una firma, y guarda los secretos en variables de entorno, nunca en el código.
  • Limita la frecuencia de las rutas caras o sensibles (inicio de sesión, envío de correos). Lo propone el ejercicio 9.
  • Cuidado con las cookies y los POST de otros sitios. Si tu handler actúa con la cookie de sesión del usuario, otra web podría provocar un POST a tu URL desde el navegador de la víctima (CSRF). La protección básica es SameSite=Lax en la cookie y comprobar la cabecera Origin en los métodos que modifican datos. Las Server Actions comprueban el origen por ti; los Route Handlers no.

15. Errores frecuentes

Síntoma Causa Solución
No response is returned from route handler ... received 'undefined' Falta return en alguna rama Devuelve un Response en todas las ramas
Conflicting route and page at /x page.tsx y route.ts en la misma carpeta Mueve el handler a otra URL (app/api/x/route.ts)
Only plain objects, and a few built-ins, can be passed... al prerenderizar un handler 'use cache' dentro del propio GET Pon 'use cache' en una función que devuelva datos
El build falla con export const dynamic Con cacheComponents, la configuración dynamic de segmento no se admite Usa await connection() para hacerlo dinámico
500 al enviar JSON mal formado request.json() lanza y no hay try/catch Captura el error y responde 400
params.id es undefined Falta await ctx.params (código de Next.js 14) const { id } = await ctx.params
La respuesta tiene datos viejos El GET es estático (○) y no lo esperabas Mira la tabla del build; usa connection() o lee la petición
405 a un OPTIONS de CORS Handler estático sin OPTIONS exportado Exporta OPTIONS con las cabeceras CORS
El navegador dice “blocked by CORS policy” pero curl funciona Faltan cabeceras Access-Control-Allow-* Sección 11; recuerda que CORS solo lo aplica el navegador
Firma del webhook siempre inválida Se leyó el cuerpo con json() y se volvió a convertir Firma sobre request.text()
Redirección a localhost en producción new URL(..., request.url) usa el nombre interno Rutas relativas con redirect() o URL pública en una variable de entorno
Un redirect() no redirige Está dentro de un try/catch que se traga la excepción Llámalo fuera del try
El stream llega todo de golpe Un proxy o la compresión acumulan la respuesta Cache-Control: no-transform y revisa el intermediario

16. Ejercicios

Los ejercicios usan la API EMPRESA en http://localhost:3000/api y Next en el puerto 3001, con EMPRESA_API en .env.local y la función error() de lib/api.ts (sección 5.2).

Ejercicio 1: Comprobación de salud

Crea GET /api/salud para que un sistema de monitorización sepa si la aplicación funciona. Debe llamar a /estadisticas de la API EMPRESA con un tiempo máximo de 2 segundos y responder { estado, api, ms, hora }: 200 con estado: "ok" si la API respondió, y 503 con estado: "degradado" si no. La respuesta no debe guardarse en ninguna caché.

Ver solución
// app/api/salud/route.ts
import { connection } from 'next/server'
import { API } from '@/lib/api'

export async function GET() {
  await connection()
  const inicio = performance.now()
  let api: 'ok' | 'caida' = 'ok'
  try {
    const res = await fetch(`${API}/estadisticas`, { signal: AbortSignal.timeout(2000) })
    if (!res.ok) api = 'caida'
  } catch {
    api = 'caida'
  }
  const ms = Math.round(performance.now() - inicio)

  return Response.json(
    { estado: api === 'ok' ? 'ok' : 'degradado', api, ms, hora: new Date().toISOString() },
    { status: api === 'ok' ? 200 : 503, headers: { 'Cache-Control': 'no-store' } },
  )
}

Salida real con la API en marcha:

200 cache-control: no-store
{"estado":"ok","api":"ok","ms":7,"hora":"2026-11-02T11:52:32.264Z"}
  • connection() hace el handler dinámico: un chequeo de salud prerenderizado en el build diría siempre “ok”.
  • AbortSignal.timeout(2000) cancela el fetch a los 2 s. Sin él, si la API se cuelga, el chequeo se colgaría también, y un monitor que no recibe respuesta no sabe qué pasa.
  • Un fetch que falla por red lanza una excepción (no devuelve res.ok === false), por eso hace falta el try.
  • 503 (Service Unavailable) es el código que entienden los balanceadores para dejar de enviar tráfico a una instancia.

Ejercicio 2: Empleados de un departamento

Crea GET /api/departamentos/[id]/empleados que devuelva { departamento, total, empleados } con solo id, nombre (nombre y apellidos juntos) y oficio de cada empleado. Si el id no es un número, 400; si el departamento no existe, 404; si la API falla, 502. Usa RouteContext.

Ver solución
// app/api/departamentos/[id]/empleados/route.ts
import { API, error, type Empleado, type Lista } from '@/lib/api'

export async function GET(_request: Request, ctx: RouteContext<'/api/departamentos/[id]/empleados'>) {
  const { id } = await ctx.params
  if (!/^\d+$/.test(id)) return error(400, `"${id}" no es un identificador`)

  const res = await fetch(`${API}/departamentos/${id}/empleados`)
  if (res.status === 404) return error(404, `No existe el departamento ${id}`)
  if (!res.ok) return error(502, 'La API EMPRESA ha fallado')

  const datos = (await res.json()) as Lista<Empleado> | Empleado[]
  const empleados = Array.isArray(datos) ? datos : datos.contenido
  return Response.json({
    departamento: Number(id),
    total: empleados.length,
    empleados: empleados.map((e) => ({ id: e.empId, nombre: `${e.nombre} ${e.apellidos}`, oficio: e.oficio })),
  })
}

Salidas reales:

GET /api/departamentos/30/empleados
200 {"departamento":30,"total":6,"empleados":[{"id":102,"nombre":"Marta Sánchez Gil","oficio":"DIRECTOR"},{"id":120,"nombre":"Pablo Moreno Herrera","oficio":"ANALISTA"},...]}

GET /api/departamentos/99/empleados   404 {"status":404,"mensaje":"No existe el departamento 99"}
GET /api/departamentos/x/empleados    400 {"status":400,"mensaje":"\"x\" no es un identificador"}

RouteContext<'/api/departamentos/[id]/empleados'> lleva la ruta completa, con el segmento estático empleados al final. El Array.isArray hace la función tolerante a que la API devuelva una lista paginada o un array simple; si conoces el formato exacto de tu API, puedes quitarlo.

Ejercicio 3: Los métodos que Next completa solo

Sin escribir código nuevo, usa curl contra /api/hola (estático, solo GET) y /api/eco (dinámico, GET y POST) en un next build + next start, y averigua: qué devuelve un DELETE, qué devuelve un HEAD, y qué devuelve un OPTIONS en cada uno. Explica la diferencia del OPTIONS.

Ver solución
curl -i -X DELETE  http://localhost:3001/api/hola
curl -I            http://localhost:3001/api/hola
curl -i -X OPTIONS http://localhost:3001/api/hola
curl -i -X OPTIONS http://localhost:3001/api/eco

Salidas reales (solo la línea de estado y la cabecera relevante):

DELETE /api/hola   ->  HTTP/1.1 405 Method Not Allowed
HEAD /api/hola     ->  HTTP/1.1 200 OK   (sin cuerpo, x-nextjs-cache: HIT)
OPTIONS /api/hola  ->  HTTP/1.1 405 Method Not Allowed   Allow: GET   Allow: HEAD
OPTIONS /api/eco   ->  HTTP/1.1 204 No Content           allow: GET, HEAD, OPTIONS, POST
  • DELETE no está exportado en ningún caso: 405.
  • HEAD funciona sin exportarlo porque existe GET.
  • El OPTIONS cambia porque /api/hola es estático: next start lo sirve desde la salida del build, que solo sabe responder a GET y HEAD. /api/eco es dinámico, la petición llega a la capa de Route Handlers, y esta genera el OPTIONS automático con todos los métodos exportados.

Consecuencia práctica: si un handler estático tiene que contestar a un preflight de CORS, exporta tú OPTIONS.

Ejercicio 4: Formulario de contacto que acepta JSON y formularios

Crea POST /api/contacto que acepte el cuerpo como JSON o como formulario (multipart/form-data o x-www-form-urlencoded). Valida con Zod: nombre (mínimo 2), email válido y mensaje (entre 10 y 1000 caracteres). Responde 201 con un id generado, 422 con detalles por campo, y 415 si el Content-Type es otro.

Ver solución
// app/api/contacto/route.ts
import * as z from 'zod'
import { error } from '@/lib/api'

const EsquemaContacto = z.object({
  nombre: z.string().trim().min(2, 'Escribe tu nombre'),
  email: z.email('El email no es válido'),
  mensaje: z.string().trim().min(10, 'El mensaje es demasiado corto').max(1000),
})

async function leerCuerpo(request: Request): Promise<unknown> {
  const tipo = request.headers.get('content-type') ?? ''
  if (tipo.includes('application/json')) return request.json()
  if (tipo.includes('form')) return Object.fromEntries(await request.formData())
  throw new Error('Tipo no admitido')
}

export async function POST(request: Request) {
  let cuerpo: unknown
  try {
    cuerpo = await leerCuerpo(request)
  } catch {
    return error(415, 'Envía JSON o un formulario')
  }

  const resultado = EsquemaContacto.safeParse(cuerpo)
  if (!resultado.success) {
    const detalles = Object.fromEntries(resultado.error.issues.map((i) => [i.path.join('.'), i.message]))
    return error(422, 'Hay datos no válidos', detalles)
  }

  const id = crypto.randomUUID()
  return Response.json({ id, recibido: resultado.data.nombre }, { status: 201 })
}

Salidas reales:

JSON {"nombre":"A","email":"x","mensaje":"hola"}
422 {"status":422,"mensaje":"Hay datos no válidos","detalles":{"nombre":"Escribe tu nombre","email":"El email no es válido","mensaje":"El mensaje es demasiado corto"}}

Formulario nombre=Ana, email=ana@correo.es, mensaje=Quiero información de los portátiles
201 {"id":"1a9dfb54-44f3-4f91-9aa8-5b6f9cb0e652","recibido":"Ana"}

text/plain "hola"
415 {"status":415,"mensaje":"Envía JSON o un formulario"}
  • tipo.includes('form') cubre los dos tipos de formulario.
  • Un JSON mal formado también acaba en el catch y da 415. Si quieres distinguirlo (400 frente a 415), separa los dos try.
  • Object.fromEntries(formData) convierte el formulario en un objeto que Zod puede validar igual que el JSON. Con campos repetidos se quedaría solo el último valor; para esos casos usa formData.getAll().
  • crypto.randomUUID() es una función global del estándar web, disponible en Node sin importar nada.

Ejercicio 5: Árbol de categorías en caché

Crea GET /api/categorias que devuelva las categorías de la API como un árbol ([{ nombre, hijas: [...] }]) y un campo generado con la hora en que se calculó. Debe salir estático en el build, con caché de días y etiqueta categorias. Después, usa /api/revalidar (sección 8.6) con inmediato: true y comprueba que la siguiente petición trae otra hora.

Ver solución
// app/api/categorias/route.ts
import { cacheLife, cacheTag } from 'next/cache'
import { API } from '@/lib/api'

type Categoria = { categoriaId: number; nombre: string; categoriaPadreId: number | null }

async function obtenerCategorias() {
  'use cache'
  cacheLife('days')
  cacheTag('categorias')

  const res = await fetch(`${API}/categorias`)
  const { contenido } = (await res.json()) as { contenido: Categoria[] }
  const raiz = contenido.filter((c) => c.categoriaPadreId === null)
  return {
    generado: new Date().toISOString(),
    arbol: raiz.map((r) => ({
      nombre: r.nombre,
      hijas: contenido.filter((c) => c.categoriaPadreId === r.categoriaId).map((c) => c.nombre),
    })),
  }
}

export async function GET() {
  return Response.json(await obtenerCategorias())
}

En el build: ○ /api/categorias 1d 1w (el perfil 'days'). Respuesta y secuencia de revalidación reales:

GET /api/categorias   x-nextjs-cache: HIT   cache-control: s-maxage=86400, stale-while-revalidate=518400
{"generado":"2026-11-02T12:06:06.823Z","arbol":[{"nombre":"Informática","hijas":["Portátiles","Ordenadores sobremesa","Periféricos"]},{"nombre":"Electrónica","hijas":["Telefonía","Audio"]},{"nombre":"Oficina","hijas":["Mobiliario"]}]}

POST /api/revalidar {"etiqueta":"categorias","inmediato":true}
{"revalidada":"categorias","inmediato":true,"cuando":"2026-11-02T12:06:43.719Z"}

GET /api/categorias   x-nextjs-cache: MISS   {"generado":"2026-11-02T12:06:44.835Z",...
GET /api/categorias   x-nextjs-cache: HIT    {"generado":"2026-11-02T12:06:44.835Z",...

El new Date() está dentro de la función cacheada: forma parte del resultado guardado, así que generado no cambia hasta que se regenera. Si lo pusieras en el GET, fuera de la caché, el handler pasaría a ser dinámico.

Ejercicio 6: RSS de novedades

Publica en /novedades.xml un canal RSS 2.0 con los cinco primeros productos del catálogo (título, enlace a https://tienda.empresa.es/productos/ID, descripción con categoría y precio). Reutiliza obtenerCatalogo() y escapa los caracteres especiales de XML.

Ver solución
// app/novedades.xml/route.ts
import { obtenerCatalogo } from '@/lib/catalogo'

const escapar = (s: string) =>
  s.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;')

export async function GET() {
  const { productos } = await obtenerCatalogo()
  const base = 'https://tienda.empresa.es'

  const items = productos
    .slice(0, 5)
    .map(
      (p) => `    <item>
      <title>${escapar(p.nombre)}</title>
      <link>${base}/productos/${p.id}</link>
      <description>${escapar(p.categoria)} - ${p.precio} EUR</description>
      <guid>${base}/productos/${p.id}</guid>
    </item>`,
    )
    .join('\n')

  const xml = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>Novedades de la tienda EMPRESA</title>
    <link>${base}</link>
    <description>Productos disponibles</description>
${items}
  </channel>
</rss>`

  return new Response(xml, { headers: { 'Content-Type': 'application/rss+xml; charset=utf-8' } })
}

Comienzo de la respuesta real:

<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>Novedades de la tienda EMPRESA</title>
    <link>https://tienda.empresa.es</link>
    <description>Productos disponibles</description>
    <item>
      <title>Portátil UltraBook 14</title>
      <link>https://tienda.empresa.es/productos/1</link>
      <description>Portátiles - 899 EUR</description>
      <guid>https://tienda.empresa.es/productos/1</guid>
    </item>
  • La carpeta se llama novedades.xml, y por eso la URL termina en .xml.
  • Como obtenerCatalogo() está cacheada y el handler no lee la petición, el build lo marca ○ /novedades.xml 1h 1d: hereda la vida del perfil 'hours'.
  • & se escapa el primero; si lo hicieras después, convertirías el & de &lt; en &amp;lt;.
  • La URL base es fija, no sale de request.url (sección 4.4). En un proyecto real vendría de una variable de entorno.

Ejercicio 7: Exportación en streaming con NDJSON

Crea GET /api/exportar/empleados que recorra todas las páginas de empleados de la API (10 por página) y envíe cada empleado como una línea JSON ({ id, nombre, departamento }) en cuanto llega su página, sin esperar a tenerlos todos. Si el cliente se desconecta, deja de pedir páginas.

Ver solución
// app/api/exportar/empleados/route.ts
import { connection } from 'next/server'
import { API, type Empleado, type Lista } from '@/lib/api'

// NDJSON: un objeto JSON por línea, enviado según llega cada página de la API
export async function GET(request: Request) {
  await connection()
  const codificador = new TextEncoder()

  const flujo = new ReadableStream<Uint8Array>({
    async start(controlador) {
      let pagina = 1
      let paginas = 1
      do {
        if (request.signal.aborted) break
        const res = await fetch(`${API}/empleados?tamano=10&pagina=${pagina}`)
        const lista = (await res.json()) as Lista<Empleado>
        paginas = lista.paginas
        for (const e of lista.contenido) {
          const linea = JSON.stringify({ id: e.empId, nombre: e.nombre, departamento: e.departamento })
          controlador.enqueue(codificador.encode(linea + '\n'))
        }
        pagina++
      } while (pagina <= paginas)
      controlador.close()
    },
  })

  return new Response(flujo, { headers: { 'Content-Type': 'application/x-ndjson; charset=utf-8' } })
}
curl -s http://localhost:3001/api/exportar/empleados | head -3
curl -s http://localhost:3001/api/exportar/empleados | wc -l
{"id":100,"nombre":"Ana","departamento":"DIRECCIÓN"}
{"id":101,"nombre":"Luis","departamento":"VENTAS"}
{"id":102,"nombre":"Marta","departamento":"INFORMÁTICA"}
25

(25 líneas: los 24 empleados de la API más la empleada creada en la sección 7.3.)

NDJSON (newline-delimited JSON) es útil para exportaciones grandes: quien lo recibe puede procesar cada línea al llegar, sin cargar un array gigante en memoria. El servidor tampoco tiene que acumularlo: solo tiene en memoria una página cada vez.

Ejercicio 8: Probar el webhook

Usando el handler de la sección 12 y el script enviar-webhook.mjs, comprueba los cinco casos: envío correcto, envío repetido, marca de tiempo antigua, firma alterada y secreto distinto. Después responde: ¿qué pasaría si quitaras la marca de tiempo de la firma (firmando solo el cuerpo) pero siguieras comprobando la cabecera x-pagos-marca?

Ver solución

Las salidas reales están en la sección 12.3: 200, 200 con duplicado, 400, 401 y 401.

Si la marca no entra en la firma, un atacante que capture una petición válida puede cambiar la cabecera x-pagos-marca por la hora actual y reenviarla: la firma (solo del cuerpo) seguiría siendo válida y la marca estaría dentro de plazo. La comprobación de tiempo no serviría de nada. Por eso la marca se firma junto con el cuerpo (${marca}.${cuerpo}): cambiarla rompe la firma.

En nuestro caso la idempotencia (el Set de ids) frenaría ese reenvío concreto, pero solo mientras el servidor recuerde el id. Las dos defensas se complementan: la marca limita el plazo, y la idempotencia cubre los reintentos legítimos dentro de ese plazo.

Ejercicio 9 (propuesto): Limitar la frecuencia de peticiones

Añade a POST /api/contacto un límite de 5 envíos por minuto y dirección IP. Guarda en un Map la IP (de la cabecera x-forwarded-for, o 'local' si no existe) con la lista de horas de sus últimos envíos. Si se supera el límite, responde 429 Too Many Requests con la cabecera Retry-After en segundos. Pruébalo con un bucle de curl. Después piensa: ¿por qué este Map no sirve si la aplicación se ejecuta en varios servidores, y qué usarías en su lugar?

Ejercicio 10 (propuesto): Cuenta atrás de una oferta con SSE

Crea GET /api/ofertas/[id]/cuenta-atras que emita un evento tic por segundo con los segundos que quedan (de 10 a 0) y un evento fin al llegar a cero. Haz un componente cliente que lo muestre con EventSource y cierre la conexión al recibir fin. Comprueba en la pestaña Red de las herramientas de desarrollo que es una sola petición que dura 10 segundos, y qué ocurre si cierras la pestaña a mitad (pista: request.signal).

Ejercicio 11 (propuesto): El catálogo en Next.js 15

Si tienes un proyecto en Next.js 15, crea /api/catalogo y /api/catalogo/[id] sin 'use cache': usa export const revalidate, fetch con next: { tags }, unstable_cache y generateStaticParams, como en la sección 8.6. Compila con next build y compara la tabla de rutas con la de Next.js 16. Añade un /api/hola sin configuración y explica por qué sale ƒ en 15 y ○ en 16.


Resumen

Concepto API Idea clave
Route Handler app/.../route.ts Una URL HTTP que devuelve lo que quieras; sin React ni layouts
Métodos export async function GET/POST/PUT/PATCH/DELETE/HEAD/OPTIONS Lo no exportado da 405; HEAD y OPTIONS se completan solos
Conflicto page.tsx + route.ts No pueden estar en la misma carpeta
Petición NextRequest, request.nextUrl.searchParams URL ya analizada; los valores siempre son cadenas
Cabeceras y cookies request.headers, request.cookies, headers(), cookies() Dos caminos equivalentes; los de next/headers llevan await
Cuerpo request.json(), formData(), text() Se lee una vez; json() lanza si no es válido: responde 400
Respuesta Response.json(datos, { status, headers }), NextResponse Estado, cabeceras y cuerpo a tu medida; 204 con cuerpo null
Redirección redirect(), NextResponse.redirect(url, 303) redirect lanza (fuera de try); request.url usa el nombre interno
Parámetros ctx.params, RouteContext<'/ruta/[id]'> Promesa; tipo global generado por Next
Caché 🔄 16.0 ○ / ƒ, connection(), 'use cache' en una función aparte Un GET sin datos de la petición se prerenderiza; los demás métodos nunca
Revalidación revalidateTag(tag, 'max' \| { expire: 0 }) max sirve lo viejo mientras regenera; expire: 0 lo descarta ya
Next.js 15 dynamic = 'force-static', revalidate, unstable_cache, revalidateTag(tag) GET dinámico por defecto
Otros formatos Content-Type, Content-Disposition CSV con BOM y ; para Excel; RSS; ficheros
Streaming new Response(new ReadableStream(...)), request.signal Envía según calculas; para si el cliente se va
SSE text/event-stream, EventSource Avisos del servidor por una conexión abierta; cierra con un evento final
CORS Access-Control-Allow-Origin, OPTIONS, Vary: Origin Lo aplica el navegador; lista blanca de orígenes
Webhooks request.text(), HMAC, timingSafeEqual, after() Firma sobre el texto crudo, marca de tiempo, idempotencia, responder rápido
BFF Handler delante de otra API Filtra campos, esconde credenciales, valida antes
Decisión Server Component / Server Action / Route Handler Tu UI: componentes y Actions; clientes externos y formatos: handlers