Pages Functions: rutas por convención de archivos
El día que Pages dejó de servir solo bytes y aprendió a ejecutar código, lo hizo con una idea prestada de los frameworks de front-end: el árbol de archivos es el router. Diseccionamos el directorio `functions/`, los segmentos dinámicos y comodines, el objeto de contexto con `params` y `next`, la cadena de middleware por directorio, y el archivo `_routes.json` que decidía qué peticiones despertaban al servidor y cuáles se resolvían con un archivo estático.
Un sitio estático puro es una idea limpia hasta que necesitas exactamente una cosa del servidor: un formulario que enviar, una clave de API que no puede viajar al navegador, una redirección que depende del país de quien llama. La respuesta obvia de la época era montar un backend aparte, y esa respuesta era desproporcionada para el tamaño del problema. Pages Functions fue la respuesta alternativa, y su apuesta de diseño se resume en una frase: no configures rutas, colócalas. Crear un archivo en un sitio del árbol equivale a declarar un endpoint, del mismo modo que los frameworks de front-end llevaban años haciendo con las páginas. Esa convención resultó comodísima para lo pequeño y reveladora de sus propios límites en cuanto el proyecto crecía, y por eso merece estudiarse con cuidado: sigue viva en miles de repositorios y su vocabulario aparece constantemente cuando toca migrar.
- Traducir una ruta HTTP a su archivo dentro de
functions/y viceversa, incluidos segmentos dinámicos y comodines. - Manejar el objeto de contexto de una invocación:
request,env,params,data,nextywaitUntil. - Encadenar middleware por directorio con
_middleware.tsy entender su orden de ejecución. - Controlar con
_routes.jsonqué peticiones invocan código y cuáles se sirven como archivo estático.
El árbol de archivos como router
La regla base es literal: la ruta de un archivo bajo functions/ es la ruta URL que atiende. Un archivo en functions/api/usuarios.ts responde a la ruta /api/usuarios, y un index.ts dentro de una carpeta atiende la ruta de esa carpeta. Sobre esa correspondencia se añaden dos convenciones tomadas del mundo de los frameworks: los corchetes simples marcan un segmento dinámico y los corchetes dobles un comodín que engulle el resto del camino.
| Archivo | Ruta atendida | Qué llega en params |
|---|---|---|
functions/api/salud.ts |
/api/salud |
Nada |
functions/api/usuarios/index.ts |
/api/usuarios |
Nada |
functions/api/usuarios/[id].ts |
/api/usuarios/42 |
El identificador como cadena |
functions/blog/[[ruta]].ts |
/blog/2026/enero/titulo |
El resto del camino como lista |
Cada archivo exporta funciones cuyo nombre codifica el verbo. onRequestGet atiende solo peticiones de lectura, onRequestPost solo envíos, y onRequest a secas actúa de comodín para cualquier verbo que no tenga manejador propio. Si una petición llega con un verbo sin manejador y sin comodín, Pages respondía con un error de método no permitido sin ejecutar tu código.
export const onRequestGet: PagesFunction<Env> = async (context) => {
const { params, env } = context;
const fila = await env.DB.prepare("SELECT * FROM usuarios WHERE id = ?")
.bind(params.id)
.first();
if (!fila) return new Response("No encontrado", { status: 404 });
return Response.json(fila);
};
export const onRequestDelete: PagesFunction<Env> = async ({ params, env }) => {
await env.DB.prepare("DELETE FROM usuarios WHERE id = ?").bind(params.id).run();
return new Response(null, { status: 204 });
};
El contexto de una invocación
Donde un Worker recibe tres argumentos sueltos —petición, entorno y contexto de ejecución—, una Pages Function recibe un único objeto que los agrupa y añade dos piezas propias. Esa diferencia de forma es pequeña en apariencia y es justamente la que hay que deshacer al migrar, así que conviene tenerla clara antes de tocar nada.
request y env
La petición estándar y el objeto con los bindings declarados. Idénticos en significado a los de un Worker, solo que anidados dentro del contexto.
params
Los segmentos dinámicos ya extraídos del camino. Es lo que en un Worker tendrías que sacar tú de la URL o delegar en un router.
data
Un saco mutable que viaja entre middleware y manejador. El canal por el que una capa de autenticación entrega la identidad ya resuelta.
next
La continuación de la cadena. Invoca el siguiente middleware o, si no queda ninguno, resuelve la petición con el archivo estático.
La pieza conceptualmente más rica es next, porque hace algo que no tiene equivalente directo en un Worker suelto: representa a la vez el siguiente eslabón de código y el sistema de archivos estático. Llamarlo sin argumentos devuelve la respuesta que Pages habría dado sin tu función, lo que permite el patrón de envolver el sitio entero para inspeccionarlo o modificarlo sin reimplementar nada.
Middleware y la cadena de next
Un archivo llamado _middleware.ts no atiende una ruta: intercepta todas las rutas de su directorio y de los directorios que cuelgan de él. Anidándolos se obtiene una cadena que se ejecuta de fuera hacia dentro, del middleware más externo al manejador final, y cuyas respuestas vuelven en sentido inverso.
flowchart TD REQ[peticion entrante] --> RAIZ[middleware raiz] RAIZ --> API[middleware de api] API --> H[manejador de la ruta] H --> API API --> RAIZ RAIZ --> RES[respuesta al cliente] RAIZ -.si no hay manejador.-> EST[archivo estatico] style H fill:#89b4fa,color:#11111b style EST fill:#f9e2af,color:#11111b style RES fill:#a6e3a1,color:#11111b
Un mismo archivo puede exportar varios middleware como lista, y se ejecutan en orden. El patrón canónico separaba una capa de observabilidad, que envuelve y deja pasar, de una capa de autorización, que puede cortar la cadena devolviendo una respuesta sin llamar nunca a next.
const registrar: PagesFunction<Env> = async (context) => {
const inicio = Date.now();
const respuesta = await context.next();
context.waitUntil(
context.env.METRICAS.writeDataPoint({
blobs: [new URL(context.request.url).pathname],
doubles: [Date.now() - inicio, respuesta.status],
}),
);
return respuesta;
};
const autorizar: PagesFunction<Env> = async (context) => {
const token = context.request.headers.get("authorization");
const identidad = token ? await verificar(token, context.env.CLAVE) : null;
if (!identidad) return new Response("No autorizado", { status: 401 });
context.data.identidad = identidad;
return context.next();
};
export const onRequest = [registrar, autorizar];
Fíjate en context.data.identidad. Ese saco compartido es el mecanismo con el que una capa entrega trabajo ya hecho a las siguientes sin recalcularlo ni volver a leer cabeceras, y es también la razón por la que un manejador de Pages podía escribirse dando por supuesto que alguien, más arriba, ya validó la sesión.
_routes.json: el mapa de invocación
Queda una decisión que la convención de archivos no puede tomar por sí sola: para una petición cualquiera, ¿hay que despertar tu código o basta con devolver un archivo del disco? Pages resolvía esto con un criterio por defecto que conviene grabar a fuego porque hoy está invertido: las funciones se evaluaban antes que los archivos estáticos. Con un middleware en la raíz, absolutamente todas las peticiones del sitio pasaban por tu código, incluidas las imágenes y las hojas de estilo.
El archivo _routes.json existía para corregir eso. Declara dos listas de patrones, las rutas que sí deben invocar código y las que deben excluirse aunque encajen en las anteriores, y la exclusión gana siempre.
{
"version": 1,
"include": ["/*"],
"exclude": ["/assets/*", "/imagenes/*", "/favicon.ico", "/robots.txt"]
}
Ese archivo no era una micro-optimización: era la diferencia entre facturar cómputo por cada recurso servido y facturarlo solo por las rutas que hacen trabajo real. En sitios con muchos recursos, escribirlo mal era el error de coste más habitual del producto, y como no rompía nada visible podía pasar meses sin detectarse.
Conviene fijarse en la asimetría de las dos listas, porque es donde se equivoca casi todo el mundo la primera vez. La inclusión se escribe casi siempre como un comodín total, porque enumerar las rutas dinámicas de un sitio que crece es una batalla perdida. Toda la precisión vive entonces en la exclusión, y una exclusión es una afirmación fuerte: prometes que por esa ruta nunca hará falta ejecutar código. El día en que alguien añade una descarga protegida bajo la carpeta de recursos, esa promesa se vuelve falsa y el archivo se sirve sin pasar por la comprobación de permisos. El patrón sano era separar por camino lo que se protege de lo que no, en vez de intentar recordar excepciones.
Existía además una tercera vía, el modo avanzado, que consistía en colocar un _worker.js en el directorio de salida y prescindir por completo de la convención de archivos. Ese modo era, sin decirlo, un Worker corriente alojado dentro de un proyecto de Pages, y por eso es el caso más fácil de migrar: el código ya está escrito en la forma de destino y solo hay que cambiarle el envase.
Pages servía tus funciones por delante de los archivos estáticos, y _routes.json servía para quitar rutas de esa evaluación. Workers hace justo lo contrario: sirve el archivo estático primero y solo llega a tu código si no hay archivo que encaje. Si migras un proyecto cuyo middleware autenticaba o registraba todas las peticiones, esa lógica dejará de ejecutarse en silencio para las rutas con archivo salvo que actives run_worker_first en el bloque assets. Es el fallo más traicionero de la migración porque no produce error: produce ausencia.
El enrutado por sistema de archivos es una de esas decisiones de diseño que se juzgan muy distinto según el día del proyecto en que las mires, y entender por qué separa al ingeniero que elige herramientas del que las padece. Su virtud real no es escribir menos: es eliminar una clase entera de errores, la de la desincronización entre dónde vive el código y qué ruta declara atender. Cuando la ubicación es la declaración, no puede haber discrepancia, no hay tabla de rutas que actualizar al mover un archivo y cualquiera que llegue nuevo al repositorio sabe leer el mapa de la aplicación sin abrir un solo archivo. Eso es enorme, y explica que la industria entera se moviera en esa dirección. Pero toda convención que elimina configuración lo hace a un precio muy concreto: convierte en rígido aquello que la configuración dejaba negociable. En cuanto necesitas algo que el árbol no sabe expresar —dos rutas que comparten manejador, un orden de precedencia que no coincide con el orden alfabético, una versión de la API que debería envolver a la anterior en vez de duplicarla— te encuentras creando archivos cuya única razón de existir es satisfacer al router, y el mapa que era transparente empieza a mentir. Ahí aparece la segunda factura, la peor: la lógica transversal se dispersa. Con middleware por directorio, responder a la pregunta más importante de cualquier auditoría, qué se ejecuta exactamente antes de esta ruta, exige recorrer el árbol hacia arriba abriendo cada carpeta, y no existe ningún sitio donde esa cadena esté escrita de forma explícita. El router de código, más verboso y aparentemente más primitivo, tiene una propiedad que la convención sacrificó sin avisar: la composición es visible y está en un archivo que puedes leer de arriba abajo. Por eso la recomendación oficial al migrar no es reproducir el árbol dentro de un Worker, sino adoptar un router explícito y aceptar el coste de escribir las rutas a mano. No es un paso atrás en comodidad; es cambiar comodidad de arranque por legibilidad de mantenimiento, que es exactamente el intercambio que un sistema hace cuando deja de ser un experimento y pasa a tener gente dependiendo de él.
- Dibuja el árbol
functions/de una API pequeña con una colección, su detalle por identificador y una ruta comodín para documentación. Anota junto a cada archivo la ruta que atiende. - Implementa una cadena de dos middleware, uno que mida el tiempo y otro que autorice, y pasa la identidad al manejador por
context.data. Comprueba qué ocurre si el segundo no llama anext. - Escribe el
_routes.jsoncorrecto para ese proyecto y razona qué recursos habrían estado invocando código innecesariamente sin él. - Traduce una de tus funciones a un manejador
fetchde Worker: deshaz el objeto de contexto en sus tres argumentos y sustituyeparamspor el router explícito que elijas. - Explica qué ruta de tu sitio dejaría de pasar por el middleware al migrar, por qué, y qué opción del bloque
assetslo corrige.