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.tscon funcionesGET,POST,PUT,PATCH,DELETE,HEADyOPTIONS, 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
ResponseyNextResponse: códigos de estado, cabeceras, cookies y redirecciones - Usar rutas dinámicas con
paramsasíncronos y el tipo globalRouteContext - 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 buildconvierte unGETen 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
POSTa 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, nierror.tsxa su alrededor: entra un objetoRequestdel estándar web y sale un objetoResponsedel estándar web. Si ya has usadofetchen el navegador, conoces las dos clases: son las mismas, pero del lado del servidor.
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 unif (req.method === 'POST'). - Usan
RequestyResponseestá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
bodyParserni configuración para leer el cuerpo: llamas arequest.json(),request.formData()orequest.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
curlusan-ipara ver también el estado y las cabeceras. En Windows,curl.exeviene instalado; en PowerShell escribecurl.exey nocurl, 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:

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.tsse queda con todos los métodos de su URL. Si tienesapp/contacto/page.tsxno puedes añadir unapp/contacto/route.ts“solo para elPOST”. Para recibir el formulario de esa página usa una Server Action (módulo 10) o pon el handler en otra URL, comoapp/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 exportasGET, Next responde aHEADcon el estado y las cabeceras de tuGET, sin cuerpo. Con/api/hola,curl -IdevuelveHTTP/1.1 200 OKy ningún cuerpo.OPTIONS: si no lo exportas, Next responde con la cabeceraAllowy 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/holaes estático:next buildlo ejecutó una vez y guardó la respuesta como un fichero (lo verás en la sección 8). A un handler estático,next startlo sirve como un fichero más, y unOPTIONSsobre él no llega a tu código: devuelve405conAllow: GETyAllow: 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 concurl -X OPTIONStras el build.
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 onullsi no está. Siempre es una cadena:?pagina=2te da'2', no2.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 darNaNsi 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')yrequest.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 elResponseque devuelves.cookies(), en cambio, sí permitesetydeletedentro de un Route Handler o una Server Action, y Next añade elSet-Cookiea 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:
request.json()lanza una excepción si el cuerpo no es JSON válido. Sin eltry/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”).- El cuerpo solo se puede leer una vez. Es un stream: cuando lo consumes con
json(), ya no puedes llamar atext(). Si necesitas el texto y el objeto, leetext()y luego hazJSON.parse(texto)tú. Así trabajan los webhooks (sección 12). - Los valores de
FormDatason siempre cadenas (oFilesi 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 derequest.url: guarda la URL pública en una variable de entorno, comohttps://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()deNextResponseescribe 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) ysecure: 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
POSTde formulario, redirige con 303: le dice al navegador que pida el destino conGET. Con 307 el navegador repetiría elPOSTcontra 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:
paramses asíncrono yRouteContextexiste 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, elawaitque falta hace queparams.idseaundefined.
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:
- 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.
- 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. - Mismo origen. Página y API están en el mismo dominio, así que no hay CORS de por medio (sección 11).
- 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
setTimeoutde 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
AbortControllercancela 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.

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"}
PUTsignifica 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íaPATCH(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
GETes dinámico salvo que lo marques, y no hay'use cache',cacheLifenicacheTagestables. Si usas Next.js 15: añade al ficheroexport const dynamic = 'force-static'oexport const revalidate = 3600, etiqueta losfetchconnext: { tags: ['productos'] }(o usaunstable_cache) y llama arevalidateTag('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
GETno 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
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
revalidateTagexige el segundo argumento. Sin él, la documentación lo da por obsoleto y se comporta como{ expire: 0 }.updateTagno 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: attachmenthace 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, elfetchsin 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:
TextEncoderconvierte texto en bytes (Uint8Array). Un stream de respuesta transporta bytes, no cadenas.request.signales unAbortSignalque 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 queaStream. Funciona al ejecutarlo, pero en un proyecto Next no compila: los tipos deReadableStreamque usa TypeScript son los del navegador (lib.dom), que no lo incluyen.tscdaProperty 'from' does not exist on type .... Usa la funciónaStreamde 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>
)
}

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 conevent: estado. Los que no llevanevent:llegan aonmessage.- Reconecta solo. Si el servidor cierra la conexión,
EventSourcevuelve a abrirla al cabo de unos segundos, y el pedido empezaría otra vez desdePENDIENTE. Por eso enviamos un eventofiny el cliente llama aclose(). - Solo admite
GETy no permite poner cabeceras comoAuthorization. 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 startno 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.
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-Origindevuelve el origen exacto que pidió, si está en la lista.Vary: Originavisa a las cachés intermedias de que la respuesta cambia según el origen. Sin ella, una CDN podría guardar la respuesta con elAllow-Originde un origen y dársela a otro. Next añade sus propias entradas aVaryy respeta la tuya.Access-Control-Max-Age: 600permite 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 leerequest.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.tso conheadersennext.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 un405sin cabeceras CORS y el navegador bloquearía la petición. Si una API pública necesita preflight, exportaOPTIONSsiempre.
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.
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é:
request.text(), norequest.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 hacesJSON.parse.- 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.
timingSafeEqualcompara 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 dosBuffermidan lo mismo, por eso se comprueba la longitud antes.- Idempotencia. Los proveedores reintentan si no reciben un 2xx a tiempo, así que el mismo evento puede llegar dos veces. Guardar los
idprocesados evita, por ejemplo, enviar dos correos de confirmación. UnSeten memoria se pierde al reiniciar y no se comparte entre servidores; en un proyecto real va a la base de datos. - Responder deprisa con
after().after(denext/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:cryptoes 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 runtimeedgecon menos APIs de Node; lo veremos al hablar deproxy.tsen 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
fetchinterno.
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
DELETEconcurl. Comprueba la sesión y el rol dentro del handler, aunqueproxy.tshaga 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
POSTde otros sitios. Si tu handler actúa con la cookie de sesión del usuario, otra web podría provocar unPOSTa tu URL desde el navegador de la víctima (CSRF). La protección básica esSameSite=Laxen la cookie y comprobar la cabeceraOriginen 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 elfetcha 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
fetchque falla por red lanza una excepción (no devuelveres.ok === false), por eso hace falta eltry. - 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
DELETEno está exportado en ningún caso: 405.HEADfunciona sin exportarlo porque existeGET.- El
OPTIONScambia porque/api/holaes estático:next startlo sirve desde la salida del build, que solo sabe responder aGETyHEAD./api/ecoes dinámico, la petición llega a la capa de Route Handlers, y esta genera elOPTIONSautomá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
catchy da 415. Si quieres distinguirlo (400 frente a 415), separa los dostry. 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 usaformData.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('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"')
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<en&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 |