Posts

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:

  1. Node.js Runtime (default) - Full Node.js with all built-ins available
  2. 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:

OpenNext build output tree.open-next/worker.jsMain Worker entry (~2-5 MB)worker.js.mapSource mapsassets/Static files (R2 / KV)cache/Pre-rendered pages (KV)_next/static/...
OpenNext build output structure

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):

  1. Build Variables - Available during next build (for things like NEXT_PUBLIC_* that get inlined)
  2. 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:

  1. Don't use @cloudflare/next-on-pages unless your entire app can run in Edge Runtime (no Node.js built-ins, no large dependencies)
  2. compatibility_date matters - April 2025+ for process.env to work
  3. nodejs_compat is required for most real-world Next.js apps
  4. Test with wrangler dev, not next dev - they have different runtime behavior
  5. 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.