> ## Content Index
> Fetch the complete content index at: https://blog.devutilites.com/llms.txt
> Use this file to discover other available public pages before exploring further.

# Cloudflare Pages a fondo: sitios estáticos con Functions, y cuándo pasarse a Workers
- URL: https://blog.devutilites.com/cloudflare-pages-a-fondo/
- Published: 2026-10-03T03:13:56.000Z
- Updated: 2026-10-03T03:13:56.000Z
- Description: Qué es Cloudflare Pages frente a Workers con static assets, despliegue con Git o Wrangler, Pages Functions con rutas por ficheros y middleware, _headers, _redirects y _routes.json, límites, precios y la migración a Workers probada paso a paso.
- Author: Luis Collantes
- Tags: Cloudflare, DevOps

**Cloudflare Pages** fue durante años la forma más cómoda de publicar una web en Cloudflare: conectas un repositorio de GitHub o GitLab, Pages construye el sitio en cada `push`, lo sirve desde toda la red y te da una URL de previsualización por cada rama. Para las partes dinámicas añadió **Pages Functions**: ficheros en una carpeta `functions/` que se convierten en rutas de una API.

Pero en 2026 hay que empezar por lo importante: **la propia Cloudflare recomienda empezar los proyectos nuevos con Workers**, que desde 2024 sirven ficheros estáticos igual de bien y tienen funciones que Pages no tiene. Pages sigue funcionando y tiene sentido para muchos sitios existentes, pero conviene saber en qué se diferencian y cómo se pasa de uno a otro.

En este artículo vamos a ver cómo funciona Pages, una aplicación de notas con Functions, middleware, cabeceras y redirecciones probada con `wrangler pages dev`, un detalle de `_routes.json` que puede costarte dinero, y la migración de esa misma aplicación a Workers, también probada.

🧪

**Todo el código de este artículo se ha ejecutado de verdad** el 2 de octubre de 2026 con **Wrangler 4.147.0**, **workerd 1.20261001.1** (en local con `wrangler pages dev` y `wrangler dev`) y **Node.js 24.21.0**. Los despliegues con Git y `wrangler pages deploy` necesitan cuenta y no los he podido ejecutar. Límites y precios comprobados en la documentación oficial ese mismo día.

## Qué es Pages

Un proyecto de Pages es un **sitio estático** (la carpeta que genera tu *build*: HTML, CSS, JS, imágenes) más, opcionalmente, **Functions** para lo dinámico. Cada despliegue es inmutable y tiene su propia URL; el de la rama de producción se sirve en `<proyecto>.pages.dev` y en tus dominios propios, y los demás quedan como **previsualizaciones** (ilimitadas).

Hay dos formas de desplegar:

1. **Integración con Git.** Conectas el repositorio desde el panel, indicas el comando de *build* y la carpeta de salida, y Pages construye en sus servidores en cada `push`. Cada rama y cada *pull request* tienen su URL de previsualización.
2. **Subida directa con Wrangler.** Construyes donde quieras (tu máquina, GitHub Actions) y subes la carpeta con `wrangler pages deploy`.

![Esquema de Cloudflare Pages: un push a Git lanza un build; el resultado se despliega con una URL por despliegue; las peticiones a ficheros estáticos se sirven directamente y las rutas de Functions ejecutan un Worker](https://blog.devutilites.com/content/images/2026/10/cf-pages-arquitectura.png)

Los estáticos se sirven gratis desde la red; solo las rutas de Functions ejecutan código y cuentan como peticiones de Workers. Diagrama: devutilites.

### Pages o Workers

|                                                  | Pages                          | Workers con static assets          |
| ------------------------------------------------ | ------------------------------ | ---------------------------------- |
| Recomendado para proyectos nuevos                | No                             | **Sí**                             |
| Ficheros estáticos gratis e ilimitados           | Sí                             | Sí                                 |
| Builds desde Git y previsualizaciones            | Sí                             | Sí (Workers Builds y Preview URLs) |
| Rutas por ficheros (functions/)                  | Sí                             | No (usas un router como Hono)      |
| Cron Triggers                                    | No                             | Sí                                 |
| Consumidores de colas                            | No                             | Sí                                 |
| Despliegues graduales                            | No                             | Sí                                 |
| Workers Logs, Logpush, Tail Workers, Source Maps | No                             | Sí                                 |
| Plugin de Vite, desarrollo con \--remote         | No                             | Sí                                 |
| Durable Objects                                  | Solo usando los de otro Worker | Sí                                 |

La tabla resume la matriz de compatibilidad de la guía oficial de migración. En la práctica: si tu proyecto necesita algo de la mitad inferior, ve a Workers.

## Primeros pasos

```bash
# Con cuenta
npx wrangler pages project create notas --production-branch main
npx wrangler pages deploy ./public              # sube la carpeta como nuevo despliegue
npx wrangler pages deploy ./public --branch=nueva-portada   # despliegue de previsualización

# En local, sin cuenta
npx wrangler pages dev                          # usa pages_build_output_dir de wrangler.jsonc
```

La configuración de un proyecto de Pages se distingue por `pages_build_output_dir`: no hay `main`, porque el código sale de la carpeta `functions/`.

```json
{
  "$schema": "node_modules/wrangler/config-schema.json",
  "name": "pages-lab",
  "compatibility_date": "2026-10-01",
  // En Pages, esto es lo que la distingue de un Worker: la carpeta del sitio ya construido
  "pages_build_output_dir": "./public",
  "kv_namespaces": [{ "binding": "NOTAS", "id": "44444444444444444444444444444444" }],
  "vars": { "NOMBRE_SITIO": "Notas en Pages" }
}
```

La estructura del proyecto de ejemplo:

```text
pages-lab/
├── wrangler.jsonc
├── functions/
│   ├── _middleware.ts          → se ejecuta antes de cada función
│   └── api/notas/
│       ├── index.ts            → GET y POST /api/notas
│       └── [id].ts             → DELETE /api/notas/:id
└── public/
    ├── index.html
    ├── assets/estilo.css
    ├── _headers                → cabeceras de los estáticos
    ├── _redirects              → redirecciones
    └── _routes.json            → qué rutas ejecutan Functions
```

## Pages Functions

Cada fichero de `functions/` es una ruta, y cada función exportada atiende un método: `onRequestGet`, `onRequestPost`, `onRequestDelete`... o `onRequest` para todos. Los corchetes en el nombre (`[id].ts`) son parámetros, y `[[ruta]].ts` captura varios segmentos.

```typescript
type Nota = { id: string; texto: string };

// GET /api/notas
export const onRequestGet: PagesFunction<Env> = async ({ env }) => {
  const { keys } = await env.NOTAS.list();
  const notas = await Promise.all(keys.map(async (k) => ({ id: k.name, texto: await env.NOTAS.get(k.name) })));
  return Response.json(notas);
};

// POST /api/notas  { "texto": "..." }
export const onRequestPost: PagesFunction<Env> = async ({ request, env }) => {
  const { texto } = await request.json<{ texto: string }>();
  if (!texto?.trim()) return Response.json({ error: "La nota está vacía" }, { status: 400 });
  const nota: Nota = { id: crypto.randomUUID(), texto };
  await env.NOTAS.put(nota.id, nota.texto);
  return Response.json(nota, { status: 201 });
};
```

```typescript
// /api/notas/:id  ->  el nombre del fichero entre corchetes es un parámetro
export const onRequestDelete: PagesFunction<Env, "id"> = async ({ params, env }) => {
  await env.NOTAS.delete(params.id as string);
  return new Response(null, { status: 204 });
};
```

El contexto que recibe cada función trae `request`, `env` (los bindings), `params`, `waitUntil`, `next` y `data` (un objeto para pasar datos del middleware a la función). Un `_middleware.ts` en una carpeta se ejecuta antes de todas las funciones de esa carpeta y sus subcarpetas:

```typescript
// Se ejecuta antes de todas las funciones y, sin _routes.json, también de los ficheros estáticos
export const onRequest: PagesFunction<Env> = async (context) => {
  const inicio = Date.now();
  const respuesta = await context.next();
  respuesta.headers.set("X-Tiempo-Ms", String(Date.now() - inicio));
  respuesta.headers.set("X-Sitio", context.env.NOMBRE_SITIO);
  return respuesta;
};
```

### Cabeceras y redirecciones sin código

`_headers` y `_redirects` en la carpeta de salida configuran los **ficheros estáticos** sin escribir una función:

```text
/*
  X-Frame-Options: DENY
  Referrer-Policy: strict-origin-when-cross-origin

/assets/*
  Cache-Control: public, max-age=31536000, immutable
```

```text
/blog            https://blog.devutilites.com   301
/antiguo/:slug   /notas/:slug                   302
```

Ambos tienen límites: 100 reglas en `_headers` y 2.000 redirecciones estáticas más 100 dinámicas (con `:parametro` o `*`) en `_redirects`. Para más, Bulk Redirects en el panel.

### Funciona así

Con `npx wrangler pages dev --port 8795` (el puerto por defecto de `pages dev` es el 8788, no el 8787 de `wrangler dev`):

```bash
$ curl -I localhost:8795/
HTTP/1.1 200 OK
referrer-policy: strict-origin-when-cross-origin
x-frame-options: DENY

$ curl -I localhost:8795/assets/estilo.css
HTTP/1.1 200 OK
Cache-Control: public, max-age=31536000, immutable
x-frame-options: DENY

$ curl -I localhost:8795/blog
HTTP/1.1 301 Moved Permanently
Location: https://blog.devutilites.com/

$ curl -I localhost:8795/antiguo/hola
HTTP/1.1 302 Found
Location: /notas/hola

$ curl -X POST localhost:8795/api/notas -H "Content-Type: application/json" -d '{"texto":"Probar Pages Functions"}' -w " %{http_code}\n"
{"id":"0d853e82-d3ee-4131-845e-146b03f5aa27","texto":"Probar Pages Functions"} 201

$ curl -X POST localhost:8795/api/notas -H "Content-Type: application/json" -d '{"texto":"  "}' -w " %{http_code}\n"
{"error":"La nota está vacía"} 400

$ curl -i localhost:8795/api/notas
HTTP/1.1 200 OK
X-Sitio: Notas en Pages
X-Tiempo-Ms: 5
[{"id":"0d853e82-d3ee-4131-845e-146b03f5aa27","texto":"Probar Pages Functions"}]

$ curl -X DELETE localhost:8795/api/notas/0d853e82-d3ee-4131-845e-146b03f5aa27 -w "%{http_code}\n"
204
```

## `_routes.json`: el fichero que te ahorra dinero

En Pages, las peticiones a ficheros estáticos son **gratis e ilimitadas**, y las que ejecutan Functions cuentan como **peticiones de Workers** (las 100.000 diarias del plan gratuito, compartidas con tus Workers). La pregunta es qué peticiones "ejecutan Functions".

🐛

**Un `_middleware.ts` en la raíz convierte cada fichero estático en una invocación.** Mi primera versión no tenía `_routes.json` y el comentario del middleware decía "no se ejecuta en los estáticos". Falso: la respuesta del CSS traía las cabeceras del middleware, es decir, había pasado por una función. En producción, cada imagen, cada CSS y cada JS habrían contado como petición facturable.

Así se veía la petición al CSS **sin** `_routes.json`:

```bash
$ curl -I localhost:8795/assets/estilo.css
HTTP/1.1 200 OK
Cache-Control: public, max-age=31536000, immutable
X-Sitio: Notas en Pages
X-Tiempo-Ms: 4
```

La solución es decirle a Pages qué rutas necesitan Functions:

```json
{
  "version": 1,
  "include": ["/api/*"],
  "exclude": []
}
```

Con él, el CSS ya no trae `X-Sitio` (lo has visto en la salida anterior) y la API sigue pasando por el middleware. Wrangler genera un `_routes.json` automáticamente a partir de tu carpeta `functions/`, pero **un middleware en la raíz hace que la ruta generada sea `/*`**; escríbelo tú en cuanto tengas uno.

## Límites y precios

| Límite                        | Free       | Pro          | Business     |
| ----------------------------- | ---------- | ------------ | ------------ |
| Builds al mes                 | 500        | 5.000        | 20.000       |
| Builds simultáneos            | 1          | 5            | 20           |
| Duración máxima de un build   | 20 minutos | 20 minutos   | 20 minutos   |
| Ficheros por sitio            | 20.000     | 100.000 (\*) | 100.000 (\*) |
| Tamaño de un fichero          | 25 MiB     | 25 MiB       | 25 MiB       |
| Dominios propios por proyecto | 100        | 250          | 500          |
| Proyectos por cuenta          | 100        | 100          | 100          |
| Previsualizaciones            | Ilimitadas | Ilimitadas   | Ilimitadas   |

(\*) Los planes de pago necesitan la variable `PAGES_WRANGLER_MAJOR_VERSION=4` en el proyecto para pasar de 20.000 a 100.000 ficheros. Fíjate en que estos planes son los de **la zona** (Free, Pro, Business), no los de Workers.

En cuanto al precio: Pages en sí es gratis. Las Functions se cobran exactamente como Workers (en Free, cuentan contra las 100.000 peticiones diarias; en Paid, contra los 10 millones mensuales), y los bindings que usen (KV, D1, R2...) se cobran como siempre. Para ficheros de más de 25 MiB, R2.

## Migrar de Pages a Workers

La guía oficial de migración se reduce, para un proyecto como este, a tres pasos que he probado:

1. Compilar las Functions en un único Worker:

```bash
$ npx wrangler pages functions build --outdir=./dist/worker
✨ Compiled Worker successfully
```

1. Escribir una configuración de Worker con `main` apuntando al resultado y `assets` apuntando a la carpeta del sitio:

```json
{
  // La misma aplicación, migrada de Pages a Workers
  "name": "notas-worker",
  "compatibility_date": "2026-10-01",
  "main": "./dist/worker/index.js",          // generado con `wrangler pages functions build`
  "assets": {
    "directory": "./public",
    "binding": "ASSETS",                     // las Functions compiladas lo usan para servir lo estático
    "run_worker_first": ["/api/*"]           // el equivalente a _routes.json
  },
  "kv_namespaces": [{ "binding": "NOTAS", "id": "44444444444444444444444444444444" }],
  "vars": { "NOMBRE_SITIO": "Notas en Workers" }
}
```

1. Excluir de los estáticos los ficheros que eran solo de Pages con un `.assetsignore` en la carpeta del sitio (aquí, una línea: `_routes.json`). Ojo: Pages no conoce `.assetsignore` y lo sirve como un fichero más (lo comprobé: `GET /.assetsignore` devuelve 200 en `pages dev`); no tiene nada sensible, pero bórralo de la carpeta cuando dejes de desplegar en Pages.

Y el resultado con `npx wrangler dev -c wrangler.worker.jsonc --port 8796`:

```bash
$ curl -X POST localhost:8796/api/notas -H "Content-Type: application/json" -d '{"texto":"Ahora en Workers"}' -w " %{http_code}\n"
{"id":"cfc1814f-6d83-453b-bf52-c3e252cf3011","texto":"Ahora en Workers"} 201

$ curl -i localhost:8796/api/notas
HTTP/1.1 200 OK
X-Sitio: Notas en Workers
[{"id":"cfc1814f-6d83-453b-bf52-c3e252cf3011","texto":"Ahora en Workers"}]

$ curl -I localhost:8796/assets/estilo.css
HTTP/1.1 200 OK
Cache-Control: public, max-age=31536000, immutable
x-frame-options: DENY

$ curl -I localhost:8796/blog
HTTP/1.1 301 Moved Permanently
Location: https://blog.devutilites.com/

$ curl -o /dev/null -w "%{http_code}\n" localhost:8796/_routes.json
404
```

Las Functions, el middleware, `_headers` y `_redirects` funcionan igual: los static assets de Workers entienden los mismos ficheros. `run_worker_first: ["/api/*"]` hace el papel de `_routes.json`: solo esas rutas ejecutan el Worker.

💡

**`pages functions build` es un puente, no el destino.** La guía de migración lo deja disponible, pero recomienda pasar a un framework si quieres seguir con rutas por ficheros (por ejemplo, HonoX) o, simplemente, escribir el Worker con un router. Para un proyecto pequeño, reescribir las tres funciones con Hono es media hora.

## Errores comunes (y cómo evitarlos)

- **Middleware en la raíz sin `_routes.json`.** Cada estático pasa a ser una petición de Workers facturable. Escribe `_routes.json` con `include` de tus rutas dinámicas.
- **Esperar que `_headers` afecte a las Functions.** Solo se aplica a los ficheros estáticos; las cabeceras de las respuestas de Functions las pones en el código (por eso la respuesta de `/api/notas` no trae `x-frame-options`).
- **Olvidar `HEAD` (y otros métodos).** Una ruta que solo exporta `onRequestGet` no atiende `HEAD`: en mis pruebas, `curl -I /api/notas` no llegó a la función, cayó en los estáticos y devolvió `200` con `Content-Type: text/html` (el `index.html`). Si un cliente o un monitor usa `HEAD`, exporta `onRequestHead` o usa `onRequest` y decide por `request.method`.
- **Confundir los planes.** Los límites de builds y ficheros van por el plan de la zona (Free, Pro, Business); el coste de las Functions, por el plan de Workers.
- **Ficheros de más de 25 MiB.** El despliegue falla. Ponlos en R2.
- **Necesitar cron o colas más tarde.** Pages no tiene Cron Triggers ni consumidores de colas. Si los vas a necesitar, empieza en Workers.
- **Superar los 100 proyectos.** El límite no se suele ampliar; para muchos sitios, Workers (500 en Paid) o Workers for Platforms.

## Cuándo usar Pages (y cuándo no)

**Pages sigue teniendo sentido** para un sitio que ya está ahí y funciona, y para webs estáticas sencillas con una o dos funciones donde la integración con Git y las previsualizaciones por rama son todo lo que necesitas. No hay prisa por migrar lo que funciona.

**Para proyectos nuevos, usa Workers con static assets**, como recomienda Cloudflare: el mismo servicio gratuito de estáticos, más funciones (cron, colas, logs, despliegues graduales, Vite) y una sola forma de trabajar para todo.

## Sigue leyendo

- [Cloudflare a fondo](https://blog.devutilites.com/cloudflare-a-fondo/): la visión general de la plataforma.
- [Cloudflare Workers a fondo](https://blog.devutilites.com/cloudflare-workers-a-fondo/): static assets, `run_worker_first` y el modo SPA, con código probado.
- [Cloudflare Workers KV a fondo](https://blog.devutilites.com/cloudflare-workers-kv-a-fondo/): el almacenamiento que usa la aplicación de notas.
- [Cloudflare R2 a fondo](https://blog.devutilites.com/cloudflare-r2-a-fondo/): para los ficheros que no caben en Pages.
- [Planes de Cloudflare Workers: Free frente a Paid](https://blog.devutilites.com/cloudflare-workers-free-vs-paid/): cómo se facturan las Functions.
- [Cloudflare Images a fondo](https://blog.devutilites.com/cloudflare-images-a-fondo/): imágenes optimizadas para tu sitio.
- [Cloudflare DNS a fondo](https://blog.devutilites.com/cloudflare-dns-a-fondo/): el dominio propio de tu proyecto.