- Start here: which adapter are you even reading about?
- Setting up the current path
- Why the Edge Runtime is different in the first place
- The breakages, in the order you’ll actually hit them
- A decision guide, not a rule of thumb
- The pattern behind all of it
- Sources
Start here: which adapter are you even reading about?
If you search for “Next.js Cloudflare edge runtime error,” you’ll land on posts describing two different tools, and mixing them up is why half the fixes you try won’t apply to your setup.
@cloudflare/next-on-pages, the adapter for Cloudflare Pages, was archived by Cloudflare on September 29, 2025. Its README now says plainly: “The next-on-pages package is deprecated, if you want to deploy a Next.js application on Cloudflare, please use the OpenNext Cloudflare adapter instead.” It forced every route in your app into the restricted V8 Edge Runtime, with no way to opt a single route back into full Node.js — which is exactly why it produced so many of the errors this post covers, for every route, all the time.
@opennextjs/cloudflare is the adapter Cloudflare’s own docs now point to for deploying Next.js to Cloudflare Workers. It takes your normal next build output and transforms it to run on Workers under the Node.js runtime, not the Edge Runtime — via a compatibility flag called nodejs_compat. That one change removes most of the edge-runtime constraints from your app by default.
So the honest framing, current as of today: if you’re starting a new deployment, you target Workers with OpenNext, and you get a Node.js-shaped environment for nearly everything. The Edge Runtime constraints below are no longer the default experience — but they’re not obsolete knowledge either. You still hit every one of them the moment you explicitly opt a route into it:
export const runtime = 'edge'
People do this on purpose for latency-sensitive routes (auth checks, redirects, A/B bucketing), and Next.js’s own Proxy file (the current name for what used to be middleware.ts) used to force this restricted runtime on you by default — as of Next.js 16 it no longer does; Proxy now defaults to the Node.js runtime and doesn’t let you configure runtime at all. But plenty of deployed apps and tutorials still predate that change, and any route you tag edge yourself still runs in the same constrained environment described below.
Setting up the current path
For a new app:
npm create cloudflare@latest -- my-app --framework=next --platform=workers
For an existing app, add the adapter and a minimal wrangler.jsonc:
npm install @opennextjs/cloudflare@latest
npm install --save-dev wrangler@latest
{
"$schema": "node_modules/wrangler/config-schema.json",
"main": ".open-next/worker.js",
"name": "my-app",
"compatibility_date": "2024-12-30",
"compatibility_flags": ["nodejs_compat"],
"assets": {
"directory": ".open-next/assets",
"binding": "ASSETS"
}
}
{
"build": "next build",
"preview": "opennextjs-cloudflare build && opennextjs-cloudflare preview",
"deploy": "opennextjs-cloudflare build && opennextjs-cloudflare deploy"
}
The one flag that matters most is nodejs_compat with a compatibility date of 2024-09-23 or later — that’s what tells the Workers runtime to expose Node built-ins instead of the bare V8 isolate. If you’re migrating off next-on-pages, remove export const runtime = 'edge' from routes that only had it because the old adapter demanded it, and remove the package itself.
Why the Edge Runtime is different in the first place
The Edge Runtime isn’t a smaller Node.js — it’s a V8 isolate with no filesystem, no OS process, and no sockets, by design. It’s built to start in single-digit milliseconds with no container boot, and that speed comes from stripping out everything that assumes a persistent OS process underneath it. Next.js’s own docs are explicit about the trade: the Edge Runtime “does not support all Node.js APIs” and “does not support Incremental Static Regeneration (ISR)”; the Node.js runtime, by contrast, “has access to all Node.js APIs.”
The Edge Runtime does give you a real set of Web APIs — fetch, Request/Response, Web Streams, crypto.subtle, TextEncoder/TextDecoder, URL, and (as a Next.js-specific polyfill) AsyncLocalStorage. What it doesn’t give you is a filesystem, native Node modules that aren’t pure ES modules, or require() — call require directly and it throws.
The breakages, in the order you’ll actually hit them
1. “Module not found: Can’t resolve ‘X’” at build time
This is almost never your own import — it’s a transitive dependency that assumes Node underneath it:
Module not found: Can't resolve 'dns'
Module not found: Can't resolve 'os'
Module not found: Can't resolve 'stream'
A common trigger chain: an HTTP-fetching library pulls in something that opens raw sockets or does DNS caching, and that dependency imports a Node built-in directly. Your code never touched it — something three or four levels down did.
Fix, in order of preference:
- Swap the dependency for one built on
fetchand Web Streams (check the package’s docs for an “edge-compatible” or “isomorphic” build). - If you’re on the OpenNext/Workers path, confirm
nodejs_compatis actually enabled for that Worker — it polyfills a real, and growing, subset of Node built-ins, includingnode:crypto,node:buffer,node:events,node:stream,node:util, andnode:net. Cloudflare’s own docs track which modules are fully supported versus partial stubs (node:dns,node:os, andnode:child_processare only partially or stub-supported as of today). - If the failure is specifically inside a route you’ve tagged
runtime = 'edge', that route is running the true restricted V8 isolate regardless ofnodejs_compatat the Worker level —nodejs_compatwidens what the Node.js runtime on Workers can do, it does not lift Next.js’s own Edge Runtime API restriction for a route you’ve explicitly opted into it. In that case, drop theedgetag unless you specifically need the latency win.
2. Database clients that hang or throw — and the part that changed
This is the section most out-of-date blog posts get wrong today. The old, and still partially true, story: traditional drivers (pg, mysql2) open raw TCP sockets, and the true Edge Runtime has no socket API, so they fail to import or hang depending on how the driver lazy-loads its transport.
// Still breaks inside a route explicitly tagged runtime = 'edge'
import { Pool } from 'pg'
const pool = new Pool({ connectionString: process.env.DATABASE_URL })
What’s changed: on the Node.js runtime on Workers (the OpenNext default), Cloudflare Workers now support outbound TCP sockets, and Cloudflare’s Hyperdrive service lets you use pg or mysql2 largely unmodified, pooling and accelerating the connection at Cloudflare’s network edge:
// Works on Workers via Hyperdrive — same pg driver, no rewrite needed
import { Pool } from 'pg'
export async function GET(_req: Request, env: { HYPERDRIVE: { connectionString: string } }) {
const pool = new Pool({ connectionString: env.HYPERDRIVE.connectionString })
const { rows } = await pool.query('SELECT id, email FROM users WHERE id = $1', [userId])
await pool.end()
return Response.json(rows)
}
The catch: every request opens a fresh connection unless you pair it with connection pooling on the database side (Hyperdrive pools on Cloudflare’s end, but you still want a small local pool size, and PgBouncer or your database’s own pooler if you’re not using Hyperdrive). And this only applies to the Node.js runtime — a route still tagged edge doesn’t get TCP sockets at all, so an HTTP-based driver is still the right call there:
// Still the right choice inside a genuinely edge-tagged route
import { neon } from '@neondatabase/serverless'
const sql = neon(process.env.DATABASE_URL!)
const rows = await sql`SELECT id, email FROM users WHERE id = ${userId}`
Neon’s HTTP mode, PlanetScale’s HTTP driver, and Upstash’s REST clients all follow this pattern and work in both runtimes, which is why they remain the safer default if you’re not sure which runtime a piece of code will end up running under.
3. Dynamic vs. static rendering surprises
The Edge Runtime doesn’t support Incremental Static Regeneration — that’s stated directly in Next.js’s own docs, not an implementation detail someone discovered by accident. If a route needs ISR, it can’t be tagged edge.
Separately, both runtimes are effectively stateless per request in a serverless deployment: a module-level variable you’re treating as an in-memory cache or a warmed singleton client resets far more often than it would on a long-lived Node server.
// Treat this as resetting on every cold start, not persisting like it would on a long-lived server
let cachedConfig: Config | null = null
If you need state that actually survives across requests, that’s what a KV namespace, Durable Objects, or an external cache is for — not a module-scope variable.
4. Bundle size limits you don’t hit until deploy
Cloudflare Workers enforce a script size ceiling that a normal next build doesn’t check for you: 3 MB (gzip-compressed) on the free plan, 10 MB on paid plans, with a 64 MB uncompressed ceiling either way. A dependency that seemed harmless locally — a full icon set imported without tree-shaking, a large date library, an entire cloud SDK pulled in for one call — can push a single route’s bundle over the limit and fail only at deploy time.
Fix: import only what you use (import { format } from 'date-fns', not the whole package), check the OpenNext build output size before deploying, and treat any route pulling in a full SDK as a candidate to keep on the Node.js runtime where at least the constraint is a size budget, not a missing API.
Free-plan Workers also cap CPU time per request at 10ms, versus up to 5 minutes (30s default) on paid plans — server-rendering anything non-trivial on the free tier is tight enough that most real Next.js deployments end up on a paid plan regardless of bundle size.
A decision guide, not a rule of thumb
Default to the Node.js runtime — which, via OpenNext on Workers with nodejs_compat, is now the normal, unremarkable path for most of a Next.js app. It gives you real database drivers via Hyperdrive, the full Node built-in surface Cloudflare currently supports, and ISR.
Reach for the Edge Runtime only for a specific route where the latency win is worth the restriction, and only when that route’s dependencies are genuinely edge-native: reads through an HTTP-based database or cache client, header rewriting, redirects, feature-flag lookups. If you’re building a new app on Next.js 16, you likely won’t touch this at all for your Proxy layer — Proxy now runs on the Node.js runtime by default and doesn’t let you opt into edge even if you wanted to.
Mixed apps are still normal: most real deployments run the bulk of their routes on the Node.js runtime and hand-pick the few that benefit from edge.
The pattern behind all of it
Every error in this post traces back to one thing: code written against assumptions — a filesystem, a raw socket, a persistent process — that a given runtime never made. The fix is never “make the constrained runtime act more like Node.” It’s knowing, per route, which runtime you’re actually in, what that runtime currently supports (because that list keeps expanding), and choosing deliberately instead of discovering it in a deploy failure.
Sources
cloudflare/next-on-pagesREADME — deprecation notice, archived Sep 29, 2025- Cloudflare Workers docs — Next.js framework guide
- OpenNext — Cloudflare adapter overview
- OpenNext — Cloudflare adapter get-started guide
- Next.js docs — Edge Runtime API reference
- Next.js docs —
proxy.jsfile convention, including version history - Cloudflare Workers docs — Node.js compatibility
- Cloudflare Workers docs — platform limits
- Cloudflare Hyperdrive docs — overview
- Cloudflare Hyperdrive docs — connect to PostgreSQL with node-postgres
- Neon docs — serverless driver
