Migrating Next.js to Cloudflare Workers with OpenNext: Debugging the Runtime Gap
December 25, 2025
In this post, I'll document the painful journey of migrating a Next.js 16 application from Vercel to Cloudflare Workers. The core issue was a fundamental incompatibility between Next.js's Node.js runtime assumptions and Cloudflare's edge runtime - something that took me days to properly understand and solve.
The first approach I tried was @cloudflare/next-on-pages, which is Cloudflare's official adapter for deploying Next.js
to Pages. I followed their documentation and ran:
bunx @cloudflare/next-on-pages
The build succeeded, but when I deployed and hit my API routes, I got this cryptic error in the Cloudflare dashboard logs:
Error: Dynamic Code Evaluation (e. g. 'eval', 'new Function', 'WebAssembly.compile') is not allowed in Edge Runtime.
Learn More: https://nextjs.org/docs/messages/edge-dynamic-code-evaluation
This error was coming from a dependency that used eval() internally. But the real problem became apparent when I tried
to use Node.js built-in modules. My API route used crypto for generating secure tokens:
// pages/api/auth/token.ts
import crypto from "crypto";
export default function handler(req, res) {
const token = crypto.randomBytes(32).toString("hex");
res.json({ token });
}
This threw a completely different error:
[Error] Cannot find module 'crypto' imported from '/functions/api/auth/token.func.js'
The problem is that @cloudflare/next-on-pages forces ALL routes to run in the Edge Runtime. The Edge Runtime is a
stripped-down JavaScript environment based on V8 isolates - it doesn't have access to Node.js built-in modules like
crypto, fs, path, buffer, etc. Cloudflare designed it this way for security isolation and fast cold starts.
But here's where it gets confusing: Next.js 13+ has TWO runtimes:
- Node.js Runtime (default) - Full Node.js with all built-ins available
- Edge Runtime - V8 isolates, no Node.js built-ins, strict size limits
When you deploy to Vercel, Next.js uses the Node.js runtime by default. When you use @cloudflare/next-on-pages, it
forces Edge Runtime for everything. This is why many Next.js apps break on Cloudflare Pages.
I verified this by checking the bundle size - @cloudflare/next-on-pages was producing ~3MB bundles per route, and Edge
function size limits can be strict depending on plan and platform. Running:
bunx @cloudflare/next-on-pages --info
...showed:
Functions:
- api/auth/token.func.js: 3.2MB (EXCEEDS LIMIT)
- api/users.func.js: 2.8MB (EXCEEDS LIMIT)
...
This is when I discovered OpenNext for Cloudflare. The key difference is that
OpenNext can run Next.js on Cloudflare Workers using nodejs_compat, which provides polyfills for many Node.js APIs.
Here's how it works under the hood:
Cloudflare Workers run on the workerd runtime (open-sourced by Cloudflare). By default, workerd is like Edge
Runtime - no Node.js built-ins. But with the nodejs_compat flag, Cloudflare enables a compatibility layer that
provides Node.js API shims. It's not complete Node.js, but it covers most common APIs: crypto, buffer, stream,
util, process, etc.
I ripped out @cloudflare/next-on-pages and installed OpenNext:
bun remove @cloudflare/next-on-pages
bun add -D @opennextjs/cloudflare wrangler
The critical configuration is in wrangler.toml. Here's my working config with explanations:
#:schema node_modules/wrangler/config-schema.json
name = "my-nextjs-app"
# Recommended: 2025-04-01 or later
# Around this date, Cloudflare improved how process.env is populated for Workers.
# Older compatibility dates can require ctx.env access instead.
compatibility_date = "2025-04-01"
# This enables Node.js API polyfills in workerd
# Without this, crypto, buffer, etc. will throw "module not found"
compatibility_flags = ["nodejs_compat"]
# Entry point - OpenNext generates this during build
main = ".open-next/worker.js"
# Asset binding for static files (CSS, JS, images)
assets = { directory = ".open-next/assets", binding = "ASSETS" }
[observability]
enabled = true
I want to emphasize the compatibility_date setting because I wasted hours on this. If you set it to anything before
April 1, 2025, you'll get this error at runtime:
TypeError: Cannot read properties of undefined (reading 'MY_VAR')
This happens because older compatibility dates didn't automatically populate process.env in the same way. You had to
access environment variables through the ctx.env object passed to your Worker's fetch handler. OpenNext expects
process.env to work in this setup, so using a newer compatibility date avoids surprises.
Next, I created the OpenNext configuration file:
// open-next.config.ts
import type { OpenNextConfig } from "@opennextjs/cloudflare";
const config: OpenNextConfig = {
default: {
override: {
// Uses Cloudflare's Node.js compatibility layer
wrapper: "cloudflare-node",
// Converts Next.js request/response to Fetch API format
converter: "edge",
// Use Cloudflare KV for ISR/SSG page caching
// This improves performance for pages that use revalidation.
incrementalCache: "cloudflare-kv",
// Stores cache tags for on-demand revalidation
tagCache: "cloudflare-kv",
// Uses Cloudflare Queues for background revalidation
// When a cached page is stale, it can queue a rebuild
// instead of blocking the request
queue: "cloudflare-queue",
},
},
};
export default config;
Each of these overrides addresses a specific incompatibility between Next.js and Cloudflare:
-
wrapper: 'cloudflare-node' - Next.js expects Node.js HTTP objects (
req,res). Workers use the Fetch API (Request,Response). This wrapper translates between them. -
converter: 'edge' - Handles the conversion of Next.js middleware and edge functions to Worker-compatible format.
-
incrementalCache: 'cloudflare-kv' - Next.js ISR (Incremental Static Regeneration) stores cached pages in
.next/cache. That doesn't exist on Workers (no filesystem). This redirects cache to Cloudflare KV.
For the KV cache to work, I had to create a KV namespace and add bindings:
bunx wrangler kv namespace create NEXT_CACHE_WORKERS_KV
This outputs something like:
⛅️ wrangler 3.100.0
-------------------
🌀 Creating namespace with title "my-nextjs-app-NEXT_CACHE_WORKERS_KV"
✨ Success!
Add the following to your configuration file:
[[kv_namespaces]]
binding = "NEXT_CACHE_WORKERS_KV"
id = "abc123def456..."
I added this to wrangler.toml:
[[kv_namespaces]]
binding = "NEXT_CACHE_WORKERS_KV"
id = "abc123def456..."
Now the build process. OpenNext has a two-stage build:
# Stage 1: Next.js builds the app normally
bun run build
# Stage 2: OpenNext transforms the output for Workers
bunx @opennextjs/cloudflare build
The second command reads .next/ and produces .open-next/ containing:
To verify locally before deploying, I use Wrangler's dev server which runs actual workerd:
bunx wrangler dev
This is different from next dev - it runs your Worker in the real Cloudflare runtime locally. I made the mistake of
only testing with next dev and getting surprised by runtime errors in production. Always test with wrangler dev
before deploying.
Speaking of debugging, when something breaks in production, wrangler tail is essential:
bunx wrangler tail
This streams real-time logs from your Worker. Here's what a successful request looks like:
GET https://my-app.workers.dev/api/users - Ok @ 1/15/2025, 10:23:45 AM
(log) [next] cache HIT for /api/users
(log) [next] revalidating in background
GET https://my-app.workers.dev/ - Ok @ 1/15/2025, 10:23:46 AM
(log) [next] rendering page /
(log) [next] ISR cache MISS, generating...
And here's what a crash looks like (the crypto issue I mentioned earlier, before nodejs_compat):
GET https://my-app.workers.dev/api/auth/token - Error @ 1/15/2025, 10:23:47 AM
(error) Error: Cannot find module 'node:crypto' imported from worker.js
at worker.js:1:2345
at async handleRequest (worker.js:1:5678)
One last gotcha: if you're using environment variables, you need to set them in TWO places for Workers Builds ( Cloudflare's CI/CD):
- Build Variables - Available during
next build(for things likeNEXT_PUBLIC_*that get inlined) - Secrets/Variables - Available at runtime (for API keys, database URLs)
You can set runtime secrets with:
bunx wrangler secret put DATABASE_URL
# Prompts for value securely
Or in wrangler.toml for non-sensitive values:
[vars]
NEXT_PUBLIC_API_URL = "https://api.example.com"
LOG_LEVEL = "info"
After all this setup, my deployment now works. The final package.json scripts:
{
"scripts": {
"dev": "next dev",
"build": "next build && bunx @opennextjs/cloudflare build",
"preview": "bunx wrangler dev",
"deploy": "bun run build && bunx wrangler deploy",
"tail": "bunx wrangler tail"
}
}
The migration took me about a week of debugging to get right. The main learnings:
- Don't use
@cloudflare/next-on-pagesunless your entire app can run in Edge Runtime (no Node.js built-ins, no large dependencies) compatibility_datematters - April 2025+ for process.env to worknodejs_compatis required for most real-world Next.js apps- Test with
wrangler dev, notnext dev- they have different runtime behavior - Set up KV bindings for ISR/caching to work properly
The app now has faster cold starts in my testing, runs on Cloudflare's global edge network, and costs significantly less.