How to Deploy a Next.js App to Cloudflare Workers in 2025
A practical guide to deploying Next.js 16 on Cloudflare Workers using @opennextjs/cloudflare — including what breaks, what to watch for, and how to get it production-ready.
Cloudflare Workers is one of the most compelling deployment targets for Next.js in 2025. Requests are handled by V8 isolates at Cloudflare's edge — cold starts measured in microseconds, no server management, and a free tier that covers most side projects and early-stage products.
But deploying Next.js to Workers is not the same as deploying to Vercel or a Node.js host. There are real constraints, and ignoring them will cost you hours of debugging.
This guide covers everything you need to get a production Next.js 16 app running on Cloudflare Workers — including the parts that most tutorials skip.
The adapter: @opennextjs/cloudflare
Next.js doesn't officially support Cloudflare Workers as a deployment target. The community adapter @opennextjs/cloudflare fills that gap. It converts the Next.js build output into a format Wrangler can deploy.
Install it alongside Wrangler:
npm install -D @opennextjs/cloudflare wrangler
Your package.json build and deploy scripts:
{
"scripts": {
"build": "next build",
"preview": "opennextjs-cloudflare build && wrangler dev",
"deploy": "opennextjs-cloudflare build && opennextjs-cloudflare deploy"
}
}
wrangler.toml — the essentials
name = "my-next-app"
main = ".open-next/worker.js"
compatibility_date = "2025-01-01"
compatibility_flags = ["nodejs_compat"]
assets = { directory = ".open-next/assets", binding = "ASSETS" }
[vars]
NEXT_PUBLIC_SITE_URL = "https://my-app.example.com"
The nodejs_compat flag is required. Without it, polyfills for Node built-ins (like crypto and Buffer) won't be available, and many npm packages will fail at runtime.
The V8 isolate constraint — what breaks
This is where most deployments run into trouble. Cloudflare Workers runs in V8 isolates, not Node.js. The following do not work at runtime:
fs,path,child_process— no filesystem accessnet,http,httpsas Node modules — use the Web Fetch API- Node
Buffer— useUint8ArrayorTextEncoder/TextDecoder - Any package that calls
require('fs')at runtime
The key phrase is at runtime. You can use fs and path in build-time code (like generateStaticParams, next.config.ts) because that code runs in Node.js during the build. It's only the request-handling code that runs in the Worker.
Reading local files in the Worker
If you're building a blog with MDX or markdown files, the standard approach of fs.readFileSync(...) will fail in production. The correct pattern is to use generateStaticParams to pre-build all pages at build time:
// app/blog/[slug]/page.tsx
export async function generateStaticParams() {
// runs in Node.js during build — fs works fine here
const files = fs.readdirSync("content/blog/en");
return files.map((f) => ({ slug: f.replace(".mdx", "") }));
}
The pages are then served as static assets from Workers' asset binding. No filesystem access at request time.
Database: use the HTTP driver, not TCP
If you're connecting to Postgres, you cannot use the standard TCP-based drivers (pg, postgres.js with TCP, etc.) — Workers can't open persistent TCP connections.
Use Neon with their serverless HTTP driver:
npm install @neondatabase/serverless drizzle-orm
import { neon } from "@neondatabase/serverless";
import { drizzle } from "drizzle-orm/neon-http";
export function getDb(databaseUrl: string) {
return drizzle(neon(databaseUrl));
}
Call getDb(env.DATABASE_URL) per request — don't cache the instance at module level, since Workers isolates don't share state across requests.
Neon's free tier is generous and the HTTP driver is built exactly for this pattern. Other options include PlanetScale (MySQL, HTTP API) and Cloudflare D1 (SQLite, native Workers binding).
Environment variables and secrets
Variables available at build time (prefixed NEXT_PUBLIC_) go in wrangler.toml under [vars]. Secret values — API keys, database URLs — should never be in wrangler.toml. Use Wrangler secrets instead:
npx wrangler secret put DATABASE_URL
npx wrangler secret put STRIPE_SECRET_KEY
npx wrangler secret put RESEND_API_KEY
For local development, create a .dev.vars file (never commit this):
DATABASE_URL=postgresql://...
STRIPE_SECRET_KEY=sk_test_...
Wrangler picks up .dev.vars automatically when you run wrangler dev.
Cloudflare bindings — R2, KV, D1
One of the real advantages of Workers is native access to Cloudflare's infrastructure. Bindings are declared in wrangler.toml and available as properties on the Workers env object.
Example: R2 for file storage:
[[r2_buckets]]
binding = "FILES"
bucket_name = "my-app-files"
Access in a route handler:
// app/api/upload/route.ts
export async function POST(req: Request, { env }: { env: { FILES: R2Bucket } }) {
const file = await req.blob();
await env.FILES.put("uploads/file.pdf", file);
return Response.json({ ok: true });
}
Note that the env binding approach requires you to access env from the request context. In Next.js App Router route handlers, you access it through the second parameter. The @opennextjs/cloudflare adapter makes this available.
Observability: logs and traces
Enable observability in wrangler.toml:
[observability.logs]
enabled = true
[observability.traces]
enabled = true
Important: Do not add [observability] enabled = false at the top level — this silently suppresses the subsections, even if they have enabled = true. The parent-level flag takes precedence over children.
You can view logs in real time with:
npx wrangler tail
For production, consider integrating with BetterStack or using a Tail Worker to forward logs to your logging backend.
Stripe webhooks: use a separate Worker
Stripe sends raw request bodies for webhook signature verification. Next.js route handlers process the request body before you can access it as raw bytes, which breaks stripe.webhooks.constructEvent().
The fix is to handle Stripe webhooks in a separate Cloudflare Worker that has direct access to the raw body:
workers/
stripe-webhook/
src/index.ts # raw body access, no Next.js abstraction
wrangler.toml # deployed separately
Point your Stripe webhook URL at webhooks.your-domain.com (mapped to this Worker) rather than your-domain.com/api/webhook. The main app and webhook Worker share the same database.
Testing the Workers build locally
Before deploying, always test with the Workers runtime locally:
npm run preview # opennextjs-cloudflare build + wrangler dev
This runs the actual Workers runtime locally on port 8787. It's the only way to catch Workers-specific issues before they hit production. npm run dev (Next.js dev server) runs in Node.js and will not catch compatibility issues.
Common issues and how to fix them
"Module not found: Can't resolve 'fs'" — A package you're importing requires fs at runtime. Either replace the package with a browser-compatible alternative, or move the usage to build-time code only.
"ReferenceError: Buffer is not defined" — Add nodejs_compat to your compatibility_flags in wrangler.toml, or replace Buffer usage with Uint8Array.
Middleware doesn't work — Cloudflare Workers supports edge middleware, but the proxy.ts feature introduced in recent Next.js versions is Node.js-only. Use middleware.ts at the project root.
Static assets 404 after deploy — Check that your assets binding in wrangler.toml points to the correct directory (.open-next/assets).
Environment variables undefined — NEXT_PUBLIC_ vars must be in wrangler.toml [vars] to be baked into the client bundle. Server-side secrets are set via wrangler secret put.
The full setup described here — Next.js 16, Cloudflare Workers, Neon Postgres, Drizzle ORM, Stripe, Clerk, and Resend — powers this platform. If you want a production-ready implementation without spending a week on infrastructure, the DevOps Accelerator package covers the entire deployment pipeline as a fixed-scope engagement.
Ready to fix this for your business?
Fixed scope, fixed price, written handover - websites, full-stack apps, and DevOps pipelines delivered in weeks, not months.