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_modulesare usable only if they ship ESM and avoid native APIs. - No dynamic code evaluation —
eval,new Function,WebAssembly.compile, andWebAssembly.instantiateare 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, andoptions.next.tagsare 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:
- 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:
ESM output alone does not guarantee safety — it must also avoid native APIs.grep -rlE "require\(|process\.binding|readFileSync|child_process" node_modules/acme/dist | head - Let
next buildbe your test. Edge-compatible code usually passes unit tests in Node (modern Node shipsfetch,crypto.subtle, andTextEncoder, 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?
2. Which statement about runtimes in Next.js 16 is accurate?
3. Which implementation is safe to import from both a middleware file and a Node.js route handler?
4. In Next.js 15, a layout exports `runtime = 'edge'` while a nested route file exports `runtime = 'nodejs'`. What runs where?
Flashcards
Question
What does the edge runtime support, and what does it explicitly deny?
Click to reveal answer
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?
Click to reveal answer
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?
Click to reveal answer
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
Click to reveal answer
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
Click to reveal answer
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
runtimeexport → declared in proxy/middleware, which does not accept it. Edge Runtime Deprecatedwarning → deleteexport 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.