Running Next.js on Cloudflare Workers: What Changes
Deploying a Next.js App Router site to Cloudflare Workers instead of Node removes the filesystem, the long-running process, and several assumptions your code is probably making. A field guide from a site that runs there.
This site is a Next.js App Router app deployed to Cloudflare Workers via @opennextjs/cloudflare — Workers with static assets, not Pages. It talks to D1 through Prisma, caches in KV, charges cards through Stripe, and serves a machine-readable profile to AI assistants.
Most of it is ordinary Next.js. The parts that are not are the parts worth writing down, because each one first appeared as a build that passed locally and a page that 500'd in production.
There is no filesystem
This is the single biggest adjustment, and it bites content-driven sites hardest.
Markdown in a content/ directory is readable at build time and gone at runtime. The deployed Worker has no disk. So any route that calls fs.readFile must either be prerendered or not exist.
Two mechanisms cover it:
// A route that reads from disk must be static — rendered at build, served as bytes.
export const dynamic = 'force-static';// And a dynamic content route must refuse slugs it did not build.
export const dynamicParams = false;That second line is the one people miss. Without it, /blog/anything-at-all tries an on-demand render, hits a filesystem that is not there, and returns a 500 instead of a 404. With it, an unlisted slug 404s — which is both correct and much easier to debug.
Warning
This failure is invisible in next dev and invisible in next build. It appears only on the deployed Worker, for a URL nobody tested. Preview the actual Workers build before shipping, not just the dev server.
The process does not stay alive
On Node, firing an unawaited promise from a request handler usually works — the process is still there when it resolves. On Workers, the runtime is entitled to tear down your execution context the moment you return a response. A floating promise is cancelled mid-flight, silently.
Anything that should happen after the response goes through the execution context explicitly:
// Calendar sync, webhooks, analytics — work the caller should not wait for,
// but that must still be allowed to finish.
getCtx().waitUntil(syncToCalendar(booking));Two things to get right. Do not await this on the response path — that defeats the purpose and puts a third party's latency in front of your user. And do not destructure waitUntil off the context; it needs its this, and the unbound call fails at runtime in a way that reads as a mysterious missing side effect.
Connections are per-request, not pooled
A traditional Node app opens a database pool at boot and reuses it for the life of the process. A Worker has no boot and no life.
With D1 this resolves neatly, because D1 is not a socket — it is a binding, handed to your Worker per request. Prisma reaches it through a driver adapter rather than a connection URL:
datasource db {
provider = "sqlite"
// No `url` — the adapter supplies the connection.
}The CLI still needs a URL for migrations, so that lives in prisma.config.ts pointing at the local wrangler sqlite file. Splitting the two is not ceremony: it is what stops a production connection string existing anywhere in the deployed bundle.
If you are coming from Postgres and reaching for the same pooled client, that is the assumption to unpick first.
Observability is opt-in, and off by default
{
"observability": { "enabled": true }
}Without that line, production logs are discarded. Not throttled, not sampled — discarded. You will find this out while debugging something urgent, which is the worst possible time, so put it in both wrangler.jsonc files on day one and never touch it again.
Unique constraints replace read-then-write
Edge code runs in many places at once, and the "check if it exists, then insert" pattern has a race window that a single-region Node app mostly gets away with.
The durable version pushes the check into the database and treats the collision as a normal outcome:
try {
await prisma.booking.create({ data: { date, time, ... } });
} catch (err) {
// A unique index on (date, time) means this is "slot taken" —
// an expected branch of the booking flow, not an error to log.
if (isUniqueViolation(err)) return slotAlreadyBooked();
throw err;
}Reading P2002 as a failure rather than a state is how double-booking bugs survive review: the code looks defensive, and the wrong slot gets sold twice anyway.
What it buys you
Having listed the constraints, the trade is worth naming. You get a site with no cold start worth measuring, served from wherever the visitor is, with the database and cache as bindings rather than network hops, on an infrastructure bill that for a site this size rounds to nothing.
For a marketing site with a booking flow and a handful of API routes, that is a straightforwardly better deal than a container that idles all night. For an app with long-running jobs, large in-memory caches, or a heavy Node dependency tree, it is not — and the honest answer there is a different runtime, not a workaround.
Related
Deployment and runtime choices like this are what the DevOps and infrastructure work here covers, and the cloud cost and architecture review exists for teams who want the trade-off assessed before committing to it. If you are mid-migration and something only breaks in production, describe it and I will tell you what I think it is.
Hitting something like this in your own codebase?
Describe the problem and get an automated scoping estimate in seconds, with the option to book a free 30-minute diagnostic call.