Next.js on Cloudflare Workers: The Adapter Is the Easy Part
OpenNext runs our production Next.js site on Cloudflare Workers. The cost was never the adapter; it was the platform's rules, each learned as a failure.

Next.js on Cloudflare Workers: The Adapter Is the Easy Part
This week someone on r/webdev asked: Anybody had experience migrating from NextJS to OpenNext / Vinext / Tanstack?
Of the three options in that title, we can only speak to one: OpenNext on Cloudflare Workers. In August 2026 we moved this site off a container platform onto Workers, with D1 as the database and R2 for storage. Production runs on it today, and it works.
But the title's framing hides the useful part. With OpenNext you are not migrating away from Next.js. You keep Next.js and move it onto a different platform, and that platform has rules of its own.
The adapter is the easy part
Getting the adapter to turn our build into a Worker was not where the time went. The time went into discovering what Workers expect of a Next.js app that a Node server never asked for.
Very few of those expectations show up as build errors. They show up as a 404 on a page that exists, a middleware bundle about six times larger than it needed to be, or a title and canonical link that land outside the head. What follows is each rule we ran into, with the symptom that taught it to us.
The runtime: build it now or never
No code generation at runtime. Our MDX content layer compiles each page
into a function with new Function at render time. Workers forbid that. So
every MDX page, docs and blog alike, has to be rendered at build time, and each
of those routes carries three exports:
export function generateStaticParams() {
return allPosts.map((post) => ({
lang: post.lang,
slug: post.slugAsParams.split("/"),
}));
}
export const dynamicParams = false;
export const dynamic = "force-static";The consequence is easy to underrate: a URL that was not prerendered is a hard 404, not a slow render. Nothing renders on demand to cover for a missing path. This week a change to our blog index linked fallback posts under the wrong language prefix; every one of those links would have been a 404. A review before release caught it before deploy.
Middleware stays on the edge. The adapter did not support Next 16's
Node-runtime proxy middleware, so ours is still an edge middleware.ts. Its
size matters. We replaced the auth library's auth() wrapper in middleware with
reading the JWT directly via getToken, and the middleware bundle went from
about 6.1 MB to about 1 MB.
Static is the fast path, so make more of it static. Reading the session in
the shared layout kept marketing pages from being fully static. We moved that
read into a client component, and prerendered routes went from 846 to 1,116.
One catch: edge caching of those pages only works on a custom domain. The
workers.dev hostname has no Cache API, so there every request ran the Worker
and read R2.
Your build output is now your problem
The prerender cache lives in R2, and uploading it is on you. The adapter's populate step used a tunnel that always timed out from our network, so we upload the cache with our own script. The object key is not negotiable:
incremental-cache/<buildId>/sha256("/<route>").cache
Our first attempt used path-based keys. Every docs and blog page answered 404
with a NoFallbackError. Before you trust your own uploader, fetch one
prerendered page from the deployed Worker.
The build bakes your environment into the artifact. The build-time environment ends up inside the Worker bundle in plain text. At runtime, Cloudflare's values are assigned first and the baked ones only fill gaps, so a baked copy of a secret does nothing except sit readable in the artifact. Our deploy deletes the baked copy of every key that also exists as a Worker secret before upload. A recent deploy removed 50 values.
A 20 MB upload is not a small API call. Uploading the roughly 20 MB bundle
through a local HTTP proxy failed with EPIPE or fetch failed every time,
while small API calls through the same proxy worked. Our deploy now bypasses
the proxy.
Stage the deploy. wrangler versions upload creates a candidate at 0%
traffic. A smoke test hits that candidate using Cloudflare's version-override
header, and only then is it promoted to 100%. The previous version id is
recorded for rollback.
The defaults are not the ones you assumed
Crawlers read the head. This one comes from Next 16 rather than Workers,
but it belongs on the same list. By default Next 16 streams page metadata after
</head> for Googlebot on dynamic pages. We added Googlebot, Bingbot and
several AI crawlers to htmlLimitedBots, so the canonical link and title are
inside <head>, where crawlers read them.
D1 is SQLite, not Postgres. Timestamps are ISO strings end to end. Booleans are 0 and 1 on the wire. Our rate limiter's single multi-key upsert, which relied on Postgres array parameters, became one atomic upsert per key.
A checklist before you migrate
- Search your content pipeline and dependencies for anything that compiles code at request time. It has to move to build time.
- Treat every route you need as something that must be prerendered. Check the generated links against the generated paths.
- Keep middleware on the edge runtime and watch its bundle size.
- Decide how the R2 cache gets populated, and prove the key format with one real page.
- Look inside the built Worker for your environment values, and strip anything that is also a secret.
- Upload a candidate version, smoke-test it at 0% traffic, record the version it replaces.
- Put the crawlers you care about in
htmlLimitedBotsand read the served head yourself. - Test caching on your real domain, not on
workers.dev. - Budget for the SQL dialect: dates, booleans, and any query built on arrays.
OpenNext answers "can Next.js run on Workers": yes, well enough for production. It does not answer "what does Workers expect of my app". That list is the migration, and most of it only shows up once you are running on the platform.