Next.js 16 on Cloudflare Workers: What Breaks and How We Fixed It
This site runs on Cloudflare Workers with Next.js 16. It answers the first byte in 7 milliseconds and served 213,000 requests last month inside the free tier. Getting there was not a matter of flipping three switches: there was one hard wall and a handful of traps that are not documented together anywhere.
Here is what we hit, with the code that shipped.
The wall: Next 16's Proxy runs on Node
Next.js 16 renamed middleware to Proxy and always runs it on the Node runtime. @opennextjs/cloudflare does not support that yet, so the build simply aborts:
Node.js middleware is not currently supported
There is no flag to disable it and no way to ask for the edge runtime. If your proxy.ts does anything — negotiate a locale, rewrite routes, check auth — that logic has to move somewhere else.
Where it goes is the Worker entrypoint. In wrangler.jsonc you point main at your own file instead of at the handler OpenNext generates:
{
"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",
},
}
That file intercepts the request, does what the Proxy used to do, and only then delegates:
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);
},
};
It is more code than a proxy.ts, but it runs before Next and without booting anything: it is the first thing that touches the request at the edge.
Trap 1: you have to reimplement the matcher
Next's Proxy has a declarative matcher. There is no such thing in a Worker: if you do not filter, your logic runs for /_next/static/..., for every image and for every file.
const EXCLUDED = /^\/(?:_next|ingest)(?:\/|$)/;
function hasFileExtension(pathname: string): boolean {
const lastSegment = pathname.split("/").pop() ?? "";
return /\.[a-zA-Z0-9]+$/.test(lastSegment);
}
The detail that cost us a while: the extension check has to look at the last segment only. A slug like next-16.3-what-changed contains a dot and is not a file. Match against the whole pathname and that post stops resolving.
Trap 2: the analytics proxy breaks trailing slashes site-wide
We serve PostHog under our own domain so blockers do not filter the events. That needs skipTrailingSlashRedirect, because PostHog's endpoints depend on the trailing slash and Next's automatic redirect breaks them:
// next.config.ts
skipTrailingSlashRedirect: true,
The problem is that the option is not per-route: it disables trailing-slash normalisation for the entire site. Without it, /en and /en/ are two different URLs returning 200 with identical content. That is duplicate content, and on a new site it is the last thing you want to hand Google.
So you put it back by hand, in the Worker:
if (pathname.length > 1 && pathname.endsWith("/")) {
const target = new URL(url);
target.pathname = pathname.replace(/\/+$/, "");
return Response.redirect(target.toString(), 308);
}
It is a worthwhile trade, but nothing warns you that you are making it.
Trap 3: the Vary almost nobody sets
If you negotiate locale from Accept-Language, the redirect you return depends on the visitor's header. Without Vary: Accept-Language, any CDN in the path can cache the 307 that points at /en and later serve it to someone who asked for Spanish.
There is an implementation detail too: Response.redirect() returns immutable headers, so you cannot add Vary afterwards. You have to build the Response yourself:
return new Response(null, {
status: 307,
headers: { Location: target.toString(), Vary: "Accept-Language" },
});
This is the only response in the whole Worker that carries Vary. Setting it everywhere would be worse: every Accept-Language variant would become its own cache entry and the hit rate would collapse.
Trap 4: you probably do not need R2
The OpenNext docs push you toward an incremental cache on R2 or KV. If all of your content is prerendered at build time — pages, i18n routes, MDX posts — the cache never has to write: it only has to read what already sits in the Worker's static assets.
import staticAssetsIncrementalCache from "@opennextjs/cloudflare/overrides/incremental-cache/static-assets-incremental-cache";
export default defineCloudflareConfig({
incrementalCache: staticAssetsIncrementalCache,
});
Zero bindings, zero cost, zero added latency. The day a real revalidate shows up you have to move to r2-incremental-cache plus an R2 binding, because this override does not support writes. Until then, adding it is infrastructure you pay for and never use.
And HTML caching goes in the Worker
Static assets already ship with long headers. HTML does not, and it is exactly where the win is:
headers.set("Cache-Control", "public, max-age=0, s-maxage=86400, stale-while-revalidate=604800");
max-age=0 so the browser always revalidates, s-maxage=86400 so the edge serves it for a day without going back to the origin, and a week of stale-while-revalidate so a fresh deploy does not cause a spike of misses. It is one line, and it is the one that puts TTFB into single digits.
What you get
Measured with Lighthouse on this very site, desktop profile, median of three runs:
| Metric | Value |
|---|---|
| Server response time | 6 ms |
| First Contentful Paint | 362 ms |
| Performance | 100 |
| CLS | 0 |
| Requests / 30 days | 213,000 |
| Hosting cost | US$0 |
For comparison: the WordPress we migrated for Finca Flichman answered in 105 ms on Apache and PHP running locally, with no network latency in the way. In production it was worse. The full numbers from that comparison are in the migration measurement.
When it is not worth it
- If you need ISR with frequent revalidation. It works, but it stops being free and adds R2 or KV, with the complexity that brings.
- If you depend on libraries that assume full Node.
nodejs_compatcovers a lot, but not everything: anything touching the filesystem at runtime does not exist in Workers. That is why every MDX post on this site is prerendered at build withdynamicParams = false, and any slug that does not exist is a 404. - If your team does not want to maintain its own entrypoint.
custom-worker.tsis your code: when the adapter supports the Node Proxy, someone has to decide whether to move back.
For a site whose content is settled at build time — corporate, blog, catalogue, landing page — the trade is a good one: single-digit TTFB, nothing to fall over, and a bill of zero.