Saltar al contenido principal
LYKOS

Next.js 16 en Cloudflare Workers: qué se rompe y cómo lo resolvimos

Lykos6 min de lectura

Este sitio corre en Cloudflare Workers con Next.js 16. Responde el primer byte en 7 milisegundos y sirvió 213.000 requests el último mes dentro del plan gratuito. Llegar ahí no fue configurar tres cosas: hubo un muro concreto y algunas trampas que no están documentadas juntas en ningún lado.

Esto es lo que encontramos, con el código que quedó en producción.

El muro: el Proxy de Next 16 corre en Node

Next.js 16 renombró el middleware a Proxy y lo ejecuta siempre en el runtime de Node. @opennextjs/cloudflare todavía no lo soporta, así que el build directamente aborta:

Node.js middleware is not currently supported

No hay flag que lo desactive ni forma de pedirle el runtime edge. Si tu proxy.ts hace algo —negociar idioma, reescribir rutas, chequear auth— ese algo tiene que mudarse a otro lado.

El lugar donde va es el entrypoint del Worker. En wrangler.jsonc se apunta main a un archivo propio en vez de al handler generado por OpenNext:

{
  "name": "lpage",
  "main": "custom-worker.ts",
  "compatibility_date": "2026-08-10",
  "compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"],
  "assets": {
    "directory": ".open-next/assets",
    "binding": "ASSETS",
  },
}

Ese archivo intercepta el request, hace lo que hacía el Proxy, y sólo entonces delega:

import { default as handler } from "./.open-next/worker.js";

export default {
  async fetch(request, env, ctx) {
    const redirect = route(request);
    if (redirect) return redirect;
    return handler.fetch!(request, env, ctx);
  },
};

Es más código que un proxy.ts, pero corre antes que Next y sin arrancar nada: es la primera cosa que toca el request en el borde.

Trampa 1: el matcher hay que reimplementarlo

El Proxy de Next tiene un matcher declarativo. En el Worker no existe: si no filtrás, tu lógica se ejecuta también para /_next/static/..., para cada imagen y para cada archivo.

const EXCLUDED = /^\/(?:_next|ingest)(?:\/|$)/;

function hasFileExtension(pathname: string): boolean {
  const lastSegment = pathname.split("/").pop() ?? "";
  return /\.[a-zA-Z0-9]+$/.test(lastSegment);
}

El detalle que nos costó un rato: el chequeo de extensión tiene que mirar sólo el último segmento. Un slug como next-16.3-que-cambia tiene un punto y no es un archivo. Si comparás contra el pathname completo, ese post deja de funcionar.

Trampa 2: el proxy de analítica rompe la barra final de todo el sitio

Servimos PostHog bajo nuestro propio dominio para que los bloqueadores no filtren los eventos. Eso necesita skipTrailingSlashRedirect, porque los endpoints de PostHog dependen de la barra final y el redirect automático de Next los rompe:

// next.config.ts
skipTrailingSlashRedirect: true,

El problema es que esa opción no es por ruta: desactiva la normalización de barra final para el sitio entero. Sin ella, /es y /es/ son dos URLs distintas devolviendo 200 con el mismo contenido. Eso es contenido duplicado, y en un sitio nuevo es lo último que querés regalarle a Google.

Así que hay que reponerla a mano, en el Worker:

if (pathname.length > 1 && pathname.endsWith("/")) {
  const target = new URL(url);
  target.pathname = pathname.replace(/\/+$/, "");
  return Response.redirect(target.toString(), 308);
}

Es un intercambio que vale la pena, pero nadie te avisa que lo estás haciendo.

Trampa 3: el Vary que casi nadie pone

Si negociás idioma por Accept-Language, el redirect que devolvés depende del header del visitante. Sin Vary: Accept-Language, cualquier CDN en el camino puede cachear el 307 que manda a /es y servírselo después a alguien que pidió inglés.

Y hay un detalle de implementación: Response.redirect() devuelve headers inmutables, así que no podés agregarle el Vary después. Hay que construir el Response a mano:

return new Response(null, {
  status: 307,
  headers: { Location: target.toString(), Vary: "Accept-Language" },
});

Este es el único response de todo el Worker que lleva Vary. Ponerlo en todos sería peor: cada variante de Accept-Language se convertiría en una entrada de cache distinta y el hit rate se desplomaría.

Trampa 4: probablemente no necesitás R2

La documentación de OpenNext empuja hacia un cache incremental en R2 o KV. Si todo tu contenido se prerenderiza en build —páginas, rutas i18n, posts de MDX— el cache no tiene que escribir nunca: sólo leer lo que ya está en los static assets del Worker.

import staticAssetsIncrementalCache from "@opennextjs/cloudflare/overrides/incremental-cache/static-assets-incremental-cache";

export default defineCloudflareConfig({
  incrementalCache: staticAssetsIncrementalCache,
});

Cero bindings, cero costo, cero latencia extra. El día que aparezca un revalidate de verdad hay que pasar a r2-incremental-cache más un binding de R2, porque este override no soporta escrituras. Mientras no exista, sumarlo es infraestructura que se paga y no se usa.

Y el cache de HTML se pone en el Worker

Los assets estáticos ya vienen con headers largos. El HTML no, y es justo donde más rinde:

headers.set("Cache-Control", "public, max-age=0, s-maxage=86400, stale-while-revalidate=604800");

max-age=0 para que el navegador siempre revalide, s-maxage=86400 para que el borde lo sirva un día sin volver al origen, y una semana de stale-while-revalidate para que un deploy nuevo no genere un pico de misses. Es una línea y es la que hace que el TTFB quede en un dígito.

Qué se gana

Medido con Lighthouse en este mismo sitio, perfil desktop, mediana de tres corridas:

MétricaValor
Respuesta del servidor6 ms
First Contentful Paint362 ms
Performance100
CLS0
Requests / 30 días213.000
Costo de hostingUS$0

Para comparar: el WordPress que migramos para Finca Flichman respondía en 105 ms sobre Apache y PHP corriendo en local, sin latencia de red de por medio. En producción era peor. Los números completos de esa comparación están en la medición de la migración.

Cuándo no conviene

  • Si necesitás ISR con revalidación frecuente. Se puede, pero deja de ser gratis y suma R2 o KV, con la complejidad que eso trae.
  • Si dependés de librerías que asumen Node completo. nodejs_compat cubre mucho, pero no todo: cualquier cosa que toque el filesystem en runtime no existe en Workers. Por eso los posts de MDX de este sitio se prerenderizan todos en build, con dynamicParams = false, y cualquier slug que no exista es 404.
  • Si tu equipo no quiere mantener un entrypoint propio. El custom-worker.ts es código tuyo: cuando el adapter soporte el Proxy de Node, va a haber que decidir si volver.

Para un sitio cuyo contenido se define en build —institucional, blog, catálogo, landing— el intercambio es bueno: un dígito de TTFB, nada que se caiga, y una factura de cero.

Artículos relacionados

Usamos cookies analíticas (PostHog) para entender cómo se usa el sitio y mejorarlo. No vendemos tus datos ni los compartimos con terceros.