Skip to content
advanced Phase 15 · Middleware and Edge Runtime

Edge vs Node Runtimes

Choose where code executes and know the limits of each.

45m
0 problems
Topic Progress 0%

The Edge Runtime's Sandbox: Web APIs In, Node APIs Out

Your route handler reads a JSON config with fs.readFileSync and works. You copy the same helper into middleware and the build dies before the dev server answers a request. The difference is not your code — it is which of Next.js's two server runtimes executes it.

The Node.js runtime is the default: the full Node standard library, native modules, require, long-running processes, ISR. The Edge runtime is a different animal — the API surface you would get inside a worker/JavaScript isolate: ECMAScript plus Web APIs, no host operating system behind it. The docs are blunt about the boundary: "Native Node.js APIs are not supported. For example, you can't read or write to the filesystem."

What the edge runtime actually provides:

Category Available
Network fetch, Request, Response, Headers, FormData, Blob, WebSocket
Crypto crypto, SubtleCrypto, CryptoKey — the Web Crypto API, not node:crypto
Encoding TextEncoder/TextDecoder, atob/btoa
Streams ReadableStream, WritableStream, TransformStream
Web standards URL, URLPattern, AbortController, structuredClone, Intl, queueMicrotask, setTimeout
Next polyfill AsyncLocalStorage
Environment process.env for environment variables

And what it refuses:

  • No native Node APIs — fs, net, tls, child_process. No sockets, no disk, no subprocesses.
  • No CommonJS require() — ES modules only; node_modules are usable only if they ship ESM and avoid native APIs.
  • No dynamic code evaluation — eval, new Function, WebAssembly.compile, and WebAssembly.instantiate are disabled and throw if reached.
  • No ISR — the edge runtime does not support incremental static regeneration.
  • No fetch caching in proxy — options.cache, options.next.revalidate, and options.next.tags are documented as having no effect there, because there is no shared cache layer behind the isolate.

Those restrictions are the price of the benefit: an isolate cold-starts in single-digit milliseconds and can be instantiated in hundreds of locations near your users, which is why request-level code (the middleware chain from Redirects and Rewrites) was moved onto it. An environment that must spin up per request cannot link a native binary, block on disk I/O, or hold a process open — so it does not let you. A synchronous readFileSync is not merely unsupported but conceptually wrong there: one isolate serves many concurrent requests on a single thread, and a blocking call stalls all of them. Dropping fs entirely is simpler than shipping a half-capable async version. ISR has the same problem — it assumes a persistent filesystem-backed cache and a long-lived process, which is why the docs list it as unsupported.

Where each runtime executes, as of the current conventions:

Code you write Runtime it runs on
Server Components rendering pages Node.js
Route handlers (route.ts) Node.js by default; 'edge' was selectable on ≤ 15
middleware.ts the edge path
proxy.ts (Next.js 16) nodejs, and it cannot be configured

Two practical consequences. First, unstable_allowDynamic in your proxy/middleware config can silence bundler complaints about dynamic-evaluation code you never execute, but if it does run, it throws — the escape hatch is for dead code paths only. Second, portability cuts both ways: anything you put in a shared module must run under the smaller of the two runtimes, because the same import may be reached from an edge segment and from a Node route handler. The mechanics of picking which runtime a route gets — and the deprecation now reshaping that choice — is the next problem.

export const runtime: Choosing the Environment for One Route

A route segment config export tells Next.js which runtime executes one file:

// app/report/route.ts  (or page.tsx / layout.tsx)
export const runtime = 'nodejs'   // default
// export const runtime = 'edge'  // Next.js ≤ 15 — deprecated in 16

The export belongs in page.tsx, layout.tsx, or route.ts, applies to that segment, cascades to everything beneath it, and a child segment may override its parent — so one export const runtime = 'edge' on a layout moves the whole subtree, while a single API route can opt back into Node:

// app/layout.tsx          → every page under it runs on edge (Next.js ≤ 15)
export const runtime = 'edge'

// app/report/route.ts      → this route alone runs Node.js
export const runtime = 'nodejs'

Read that as a policy tool: put the expensive choice on the layout, where it is visible and reviewable in one place, then make narrow exceptions per route instead of sprinkling the export across fifty files.

The version history is the part interviews actually probe, because the rule changed:

Version What changed
≤ 15.1 Middleware runs on edge only; routes may opt into 'edge'
15.2 Node.js runtime for middleware (experimental)
15.5 Node.js runtime for middleware (stable)
16.0 middleware renamed proxy; the edge runtime is NOT supported in proxy — its runtime is nodejs and it cannot be configured; runtime = 'edge' on routes is deprecated

On Next.js 16, setting export const runtime in a proxy/middleware file makes the build throw, and keeping 'edge' on a route produces the documented warning — "One or more routes in your application use export const runtime = 'edge', which is deprecated" — whose migration is simply to delete the export, since Node.js is the default. If you still need edge execution, the upgrade guide says to keep the middleware convention rather than renaming it to proxy. The headline: the runtime is chosen by framework convention, and the set of values you may declare is shrinking.

So when does each environment earn its place?

Requirement Choose
Native addon (bcrypt, sharp), Prisma engines, fs access Node.js
Packages needing real require — serverExternalPackages Node.js
ISR, long-running work, heavy CPU Node.js
Per-request personalization near the user, high-concurrency fan-out fetch edge (while still available)
Nothing above leave the default alone

Getting it wrong fails loudly, and each failure has a signature:

Symptom Cause
Module not found: Can't resolve 'fs' Node-only import inside an edge segment (or a client bundle)
Dynamic code evaluation error at build a dependency using eval/new Function
Build throws on the runtime export runtime declared in proxy/middleware — not a supported option there
Edge Runtime Deprecated warning runtime = 'edge' on a page/layout/route in Next.js 16 — delete it

One subtlety worth stating: choosing edge never makes your route handler code portable by itself. The handler may use Web APIs happily while the shared module it imports quietly uses Buffer — and the failure surfaces only at build, in the one segment you flipped. The defense is writing shared logic against the smaller runtime, which is what the last chapter covers.

Writing Shared Code That Runs in Both Runtimes

The cheapest way to survive both runtimes is to write shared modules against the edge contract from the start: if it runs on the edge, it runs on Node, but never the reverse. Validation helpers, URL builders, JWT logic, serialization — none of them should know which host they live in.

The translations you will actually use:

Reach for (Node habit) Write instead (portable)
Buffer.from(s, 'base64') atob(s) / btoa(s), or TextEncoder on bytes
crypto.randomBytes(16) crypto.randomUUID() or crypto.getRandomValues(new Uint8Array(16))
node:crypto sign/verify Web Crypto — crypto.subtle.importKey, sign, verify
require('./config') static import (ESM only on the edge)
path.join(a, b) new URL(rel, base)
process.nextTick(fn) queueMicrotask(fn)
fs.readFileSync('rules.json') import the JSON at build time, or fetch a service at request time
os.hostname() no portable equivalent — read a header or env var instead

A session check illustrates the split. Token verification is pure computation over bytes, so it belongs in a shared module using Web Crypto — the jose package implements exactly that and runs in both runtimes, which is why it is the standard choice inside middleware. Token issuance against a database, password hashing with bcrypt, and anything touching fs stays in a route handler or Server Action running on Node. The middleware makes the cheap decision; the handler does the expensive one:

// lib/session.ts — safe to import from middleware AND from route handlers
import { jwtVerify } from 'jose'
const key = new TextEncoder().encode(process.env.SESSION_SECRET!)
export async function verifySession(token: string) {
  const { payload } = await jwtVerify(token, key)
  return payload.sub as string
}

// app/api/settings/route.ts — Node.js only; never reached from middleware
import { hash } from 'bcrypt'
export async function POST(req: Request) {
  return Response.json({ hash: await hash(await req.text(), 12) })
}

Note where the boundary sits: it is the import graph, not the file you edited. If lib/session.ts ever imports lib/db.ts "just to look up a user", every consumer inherits the database driver — and the edge build fails somewhere you did not touch. Keep the shared layer leaf-only: no side effects at import time, no environment assumptions beyond process.env, no transitive reach into Node territory.

Two habits catch the rest before production does:

  1. Audit dependencies for the edge. A grep over the built output says more than the README — flag CommonJS and native touches before they reach the bundle:
    grep -rlE "require\(|process\.binding|readFileSync|child_process" node_modules/acme/dist | head
    
    ESM output alone does not guarantee safety — it must also avoid native APIs.
  2. Let next build be your test. Edge-compatible code usually passes unit tests in Node (modern Node ships fetch, crypto.subtle, and TextEncoder, so the test runner is more permissive than the edge), and the mismatch only surfaces when the edge bundle is compiled. Failing the build in CI is the cheap place to find it.

For the escape hatch, config.unstable_allowDynamic globs silence bundler complaints about dynamic evaluation in files you never execute — with the documented caveat that execution on the edge throws. Use it for proven-dead paths, not as a workaround for a dependency you actually call.

Once your code is portable, you can exploit the edge for what it is good at: making a decision per request, near the user, before the first byte of HTML exists. That is A/B Tests and Personalization at the Edge.

Quiz

1. A route declared with `export const runtime = 'edge'` imports a helper that calls `fs.readFileSync`. What does Next.js do?

Question 1 options

2. Which statement about runtimes in Next.js 16 is accurate?

Question 2 options

3. Which implementation is safe to import from both a middleware file and a Node.js route handler?

Question 3 options

4. In Next.js 15, a layout exports `runtime = 'edge'` while a nested route file exports `runtime = 'nodejs'`. What runs where?

Question 4 options

Flashcards

Question

What does the edge runtime support, and what does it explicitly deny?

Answer

Supports Web APIs: fetch, Request/Response/Headers, FormData, URL/URLPattern, Web Crypto (crypto.subtle), streams, TextEncoder/Decoder, atob/btoa, structuredClone, Intl, timers, plus an AsyncLocalStorage polyfill and process.env. Denies native Node APIs (fs, net, tls, child_process), CommonJS require (ESM only), eval/new Function/WebAssembly.compile (throw), ISR, and fetch cache options in proxy.

Question

How is a route's runtime selected, and how does the selection propagate?

Answer

`export const runtime = 'nodejs' | 'edge'` in page.tsx, layout.tsx, or route.ts. Node.js is the default; the export applies to its segment and cascades to all descendants, and any child segment can override it. The export is invalid in proxy/middleware files — setting it there throws.

Question

What changed about runtimes in Next.js 16?

Answer

`runtime = 'edge'` on routes is deprecated (Edge Runtime Deprecated warning; fix = delete the export, Node is default). Proxy/middleware: the edge runtime is NOT supported — proxy runs nodejs and it cannot be configured; keep the middleware convention if you still need edge. Middleware gained stable Node runtime support in 15.5.

Question

Four runtime failure signatures and their causes

Answer

`Module not found: Can't resolve 'fs'` → Node-only import in an edge (or client) bundle. Dynamic code evaluation error → dependency uses eval/new Function. Build throws on a `runtime` export → declared in proxy/middleware. `Edge Runtime Deprecated` warning → runtime = 'edge' on a route file in Next 16.

Question

Portable replacements for five common Node APIs in shared modules

Answer

`Buffer.from(s,'base64')` → atob/btoa + TextEncoder; `crypto.randomBytes` → crypto.randomUUID()/getRandomValues; `node:crypto` signing → crypto.subtle (jose); `require()` → static import; `path.join` → new URL(rel, base); `process.nextTick` → queueMicrotask; `fs.readFileSync` → build-time import or fetch. Unit tests in Node pass where the edge would fail, so `next build` is the real edge test.

Revision Notes

Key Takeaways

  • 1. Two server runtimes: Node.js (default — full stdlib, native modules, ISR) and edge (Web APIs only — no fs/net, ESM only, no eval, no ISR), with the edge's limits paying for isolate-level cold starts near the user
  • 2. Route segment `export const runtime` selects per file, cascades to children, and can be overridden downstream — but it is forbidden in proxy/middleware, where Next.js owns the runtime
  • 3. Next.js 16 flips the direction: edge is deprecated on routes (delete the export) and proxy is pinned to an unconfigurable nodejs runtime; edge survives only through the middleware convention
  • 4. Every mismatch has a build-time signature — `Module not found: Can't resolve 'fs'`, dynamic-evaluation errors, a throwing runtime export, or the Edge Runtime Deprecated warning
  • 5. Shared modules should target the smaller contract: Web Crypto over node:crypto, atob over Buffer, ESM imports over require, URL over path — then let `next build` compile the edge bundle as the test

Interview Tips

  • • "What's the difference between the edge and Node runtimes?" — answer with capability, not marketing: Web APIs vs full stdlib, no native modules/require/eval/ISR on edge, isolate cold start vs long-running process, then note edge's role in middleware
  • • "How do you pick a runtime for a route?" — `export const runtime` on the segment, cascading to children with child override; add that Next 16 deprecates edge on routes and makes proxy's nodejs runtime unconfigurable
  • • "Your edge route fails with Module not found: Can't resolve 'fs' — why and fix?" — the edge bundle has no Node filesystem; move the read into a Node route handler or fetch it, and keep shared modules on the Web API subset
  • • "How would you make a module usable from middleware and route handlers?" — write it against Web APIs (jose for JWT, TextEncoder/atob, URL), audit dependencies for require/fs/eval, and rely on `next build` since Node unit tests are more permissive than the edge

Cheat Sheet

Edge vs Node Runtimes

The two runtimes

Node.js runtime Edge runtime
Default? yes (routes, handlers, proxy in Next 16) opt-in (deprecated in 16)
APIs full Node stdlib + native modules Web APIs only: fetch, Request/Response/Headers, URL, Web Crypto, streams, TextEncoder, atob/btoa, structuredClone, Intl, AsyncLocalStorage polyfill
require() yes no — ESM only
fs / net / tls / child_process yes no
eval, new Function, WebAssembly.compile yes disabled — throws
ISR yes no
fetch cache options in proxy — no effect (cache, next.revalidate, next.tags)
Process model long-running, native ABI isolate, single-digit ms cold start

Route-level selection

// app/report/route.ts — also valid in page.tsx / layout.tsx
export const runtime = 'nodejs'   // default; cascades to child segments
// export const runtime = 'edge'  // Next ≤ 15 only — deprecated in 16

Version history

Version Change
≤ 15.1 middleware = edge only; routes may use 'edge'
15.2 / 15.5 Node runtime for middleware (experimental → stable)
16.0 middleware → proxy; edge NOT supported in proxy (nodejs, cannot be configured); runtime = 'edge' deprecated on routes

Error signatures

  • Module not found: Can't resolve 'fs' → Node-only import in an edge (or client) bundle.
  • Dynamic code evaluation error → dependency uses eval/new Function.
  • Build throws on runtime export → declared in proxy/middleware, which does not accept it.
  • Edge Runtime Deprecated warning → delete export const runtime = 'edge'; Node is default.

Choose Node for: native addons (bcrypt, sharp), Prisma engines, fs, serverExternalPackages, ISR, heavy CPU.
Choose edge (while available) for: per-request personalization near the user, high-concurrency fetch fan-out.

Portability rules for shared modules

Node habit Portable replacement
Buffer.from(s,'base64') atob/btoa, TextEncoder
crypto.randomBytes crypto.randomUUID() / crypto.getRandomValues
node:crypto signing crypto.subtle.* (e.g. jose)
require() static import
path.join new URL(rel, base)
process.nextTick queueMicrotask
fs.readFileSync build-time import or fetch

Escape hatch: config.unstable_allowDynamic globs (dead code only — execution on edge throws).
Detect failures early: next build compiles the edge bundle; unit tests in Node are more permissive than the edge.

Continue Learning