Type something to search...
Edge Runtime vs Node.js Runtime in Next.js: Choosing the Right One

Edge Runtime vs Node.js Runtime in Next.js: Choosing the Right One

For a few years, "should this route run on the Edge?" was a real question in Next.js projects. You could add export const runtime = "edge" to a page or Route Handler and get a lightweight, fast-starting function that ran close to your users, at the cost of losing most of Node.js. Plenty of tutorials recommended it for anything latency-sensitive, and middleware ran on the Edge by default.

That picture has changed. In Next.js 16, the Node.js runtime is the default everywhere, the new proxy.ts file (which replaces middleware.ts) only runs on Node.js, Cache Components require Node.js, and as of Next.js 16.3 setting runtime = "edge" on a route prints a deprecation warning. So the honest answer to "which one should I choose?" is now usually short. But you still need to understand the differences: you may be maintaining routes or middleware that use Edge, you may depend on libraries written with Edge constraints in mind, and you'll want to know what you give up and gain when you migrate.

This post compares the two runtimes, explains where each one still shows up, and walks through moving Edge code to Node.js without losing the performance you were after.

The Two Runtimes in One Paragraph Each

The Node.js runtime runs your server code in a regular Node.js process. You get the full Node.js standard library (fs, path, net, child_process, Buffer, streams), native addons, every npm package, and every Next.js feature: ISR, "use cache", Cache Components, Server Actions, and so on. It's the default and what Next.js uses to render your app unless you say otherwise.

The Edge runtime is a restricted JavaScript environment built on V8 isolates, modeled after the Web platform rather than Node.js. It exposes standard Web APIs such as fetch, Request, Response, Headers, URL, crypto, TextEncoder, and Web Streams, plus a polyfilled AsyncLocalStorage. It does not expose the Node.js standard library, can't load native modules, and disables dynamic code evaluation. In return, isolates start very quickly and use little memory, which made them attractive for deploying many small functions across many regions.

Side-by-Side Comparison

Node.js runtimeEdge runtime
Status in Next.js 16.3Default, recommendedDeprecated for route segments
Node.js APIs (fs, net, Buffer, etc.)YesNo
Web APIs (fetch, Request, crypto.subtle, streams)YesYes
npm packagesAllOnly ESM packages that avoid Node.js APIs
Native addons (e.g. sharp, bcrypt)YesNo
eval / new FunctionYesNo
require()YesNo (ES modules only)
ISRYesNo
Cache Components / "use cache"YesNo
proxy.tsYes (always)No
Legacy middleware.tsYesYes (default)
StreamingYesYes
Cold startSlower than an isolateVery fast

The last row is the one that used to drive decisions. The rest of the table is why those decisions often backfired.

Where Each Runtime Shows Up Today

Pages, layouts, and Route Handlers

Every route segment uses Node.js unless you export a runtime option:

// app/api/hello/route.ts
export const runtime = "nodejs"; // the default, so this line is optional

export async function GET() {
  return Response.json({ message: "Hello from Node.js" });
}

The only other accepted value is "edge", and it's marked deprecated in the docs. During next build or next dev you'll see:

The Edge Runtime is deprecated. You can use the "nodejs" runtime instead.

It still works for now, but treat it as something to remove rather than something to add.

Proxy (formerly middleware)

Next.js 16 renamed middleware.ts to proxy.ts and changed the function name from middleware to proxy. Proxy always runs on Node.js, and you can't configure that. If you export a runtime option from proxy.ts, Next.js logs an error in development and fails the production build.

// proxy.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

export function proxy(request: NextRequest) {
  const session = request.cookies.get("session");

  if (!session) {
    return NextResponse.redirect(new URL("/login", request.url));
  }

  return NextResponse.next();
}

export const config = {
  matcher: ["/dashboard/:path*", "/settings/:path*"],
};

The deprecated middleware.ts still works and still defaults to the Edge runtime, which is the one remaining place most apps run Edge code. If you need to stay on Edge temporarily, keeping middleware.ts is the escape hatch. If you want to migrate, the codemod renames the file and function for you:

npx @next/codemod@canary middleware-to-proxy .

For a deeper look at what this layer is good for, see understanding middleware in Next.js. The concepts carry over directly to Proxy.

Instrumentation

instrumentation.ts is loaded in every runtime your app uses, and process.env.NEXT_RUNTIME tells you which one you're in. This is the standard way to load Node-only tooling, such as an OpenTelemetry SDK, without breaking an Edge bundle:

// instrumentation.ts
export async function register() {
  if (process.env.NEXT_RUNTIME === "nodejs") {
    await import("./instrumentation.node");
  }
}

If your app has no Edge code left (no runtime = "edge", no middleware.ts), this check always passes, but keeping it is harmless and makes the file safe if Edge code ever comes back.

Why Edge Was Appealing, and Where It Fell Short

The pitch for Edge was simple: run your code in a data center near the user, with almost no cold start, and every request gets faster.

That holds up when the function doesn't need anything far away. A redirect based on a cookie, a header rewrite, or an A/B bucket assignment can be answered entirely on the spot. But most page renders need data, and data usually lives in one region: your Postgres instance, your MongoDB cluster, your internal API. A render running in Sydney that makes three sequential queries to a database in Virginia pays the trans-Pacific round trip three times. A render running next to the database pays it once, when the HTML travels to the user.

The other costs were practical:

  • Library compatibility. Database drivers, ORMs with native engines, image processing libraries, PDF generators, password hashing with native bindings, and many SDKs rely on Node.js APIs. Edge routes needed special "edge-compatible" variants or HTTP-based drivers.
  • Missing framework features. No ISR, and no Cache Components or "use cache". As Next.js leaned into caching as the main performance tool, Edge routes were left out.
  • Split mental model. Code that worked in one route could fail in another depending on a one-line export, and errors like "The edge runtime does not support Node.js 'crypto' module" showed up only after a change somewhere deep in the import graph.

The result is the current recommendation: render on Node.js, put that compute near your data, and get speed from caching and static output instead of from geography.

Choosing: A Practical Decision Guide

For new code in a Next.js 16 app, the decision is close to automatic:

  • Pages, layouts, Server Components, Server Actions: Node.js. Don't export runtime.
  • Route Handlers: Node.js. That includes webhooks, file uploads, streaming responses, and anything that touches a database.
  • Request interception (auth checks, redirects, rewrites, headers): proxy.ts, which is Node.js.
  • Anything using Cache Components or ISR: Node.js is required.

The only reasons to keep Edge code are transitional: a middleware.ts you haven't migrated yet, or a route that depends on platform-specific Edge behavior you can't replace this sprint. In both cases, plan the migration rather than expanding the Edge footprint.

Migrating an Edge Route to Node.js

For most routes, migration is one deleted line. Here's a typical Edge Route Handler that signs a token with the Web Crypto API:

// app/api/sign/route.ts (before)
export const runtime = "edge";

export async function POST(request: Request) {
  const { payload } = (await request.json()) as { payload: string };
  const key = await crypto.subtle.importKey(
    "raw",
    new TextEncoder().encode(process.env.SIGNING_SECRET!),
    { name: "HMAC", hash: "SHA-256" },
    false,
    ["sign"],
  );
  const signature = await crypto.subtle.sign(
    "HMAC",
    key,
    new TextEncoder().encode(payload),
  );
  const hex = Array.from(new Uint8Array(signature))
    .map((b) => b.toString(16).padStart(2, "0"))
    .join("");

  return Response.json({ signature: hex });
}

Everything here (Request, Response.json, crypto.subtle, TextEncoder) is a Web API, and Node.js supports all of them as globals. Delete the runtime export and the route runs on Node.js unchanged.

Once you're on Node.js, you're free to simplify using Node's own modules where that's clearer:

// app/api/sign/route.ts (after)
import { createHmac } from "node:crypto";

export async function POST(request: Request) {
  const { payload } = (await request.json()) as { payload: string };
  const signature = createHmac("sha256", process.env.SIGNING_SECRET!)
    .update(payload)
    .digest("hex");

  return Response.json({ signature });
}

Either version is fine on Node.js. The first is portable across runtimes; the second is shorter. Pick one style per codebase so readers aren't left wondering why two routes do the same thing differently.

Things to clean up after migrating

  • Remove edge-specific package variants. If you used an HTTP-based database driver or a /edge import path only because of Edge, you can usually switch back to the standard driver with connection pooling. Check the library's docs for the recommended Node.js setup.
  • Remove preferredRegion. It's also deprecated in 16.3 (it was mainly used together with Edge). Region placement belongs in your hosting platform's project settings.
  • Revisit export const dynamic and caching. Routes that were dynamic because Edge couldn't use ISR may now be able to cache. With Cache Components on, you can add "use cache" to the data functions that don't need per-request data.
  • Check bundle-size workarounds. Edge functions had tight size limits, which led to awkward dynamic imports. On Node.js you can often import normally again.

Getting the Speed Without Edge

If you moved to Edge for latency, here's where that latency comes from on Node.js instead.

Serve static or cached output

The fastest response is one that doesn't run your code at all. Pages that are prerendered at build time or cached are served from the CDN in front of your app, from locations near users. With cacheComponents: true in next.config.ts, you can cache at the function level:

// lib/posts.ts
import { cacheLife, cacheTag } from "next/cache";

export async function getPopularPosts() {
  "use cache";
  cacheLife("hours");
  cacheTag("posts");

  const res = await fetch("https://api.example.com/posts/popular");
  return (await res.json()) as { slug: string; title: string }[];
}

The first call computes the result; later calls within the cache lifetime reuse it. cacheTag lets you invalidate it on demand when posts change. If your site doesn't change on every request, this does more for real users than moving compute around. Our post on ISR in Next.js covers the time-based side of this in more depth.

Stream the dynamic parts

For pages that mix static and personalized content, wrap the slow, dynamic parts in Suspense. The static shell is sent immediately and the rest streams in as it resolves. Users see content quickly even when a query takes a while, and that works the same on Node.js as it did on Edge.

// app/dashboard/page.tsx
import { Suspense } from "react";
import { RecentOrders } from "./recent-orders";

export default function DashboardPage() {
  return (
    <main>
      <h1>Dashboard</h1>
      <Suspense fallback={<p>Loading orders...</p>}>
        <RecentOrders />
      </Suspense>
    </main>
  );
}

Keep quick decisions in Proxy

Redirects, rewrites, locale detection, and cookie checks were the best use case for Edge, and they belong in proxy.ts now. Keep Proxy small and fast: read a cookie or header, decide, and return. Don't query a database there, and always repeat real authorization checks in the Server Component, Server Action, or Route Handler that actually returns data. A matcher change can silently skip Proxy for a route.

Put compute next to data

Configure your hosting so server functions run in the same region as your database. One round trip from the user to that region is usually faster than many round trips from an edge location to the database.

Writing Code That Works in Both

If you maintain a library or shared package that might be used from legacy Edge middleware, stick to Web APIs and guard anything Node-specific:

NeedPortable choice
HTTP requestsfetch
Hashing, HMAC, random valuescrypto.subtle, crypto.getRandomValues, crypto.randomUUID
EncodingTextEncoder, TextDecoder, atob, btoa
StreamsReadableStream, TransformStream
JWT verificationA Web Crypto based library such as jose
Request contextAsyncLocalStorage (polyfilled on Edge)

Avoid top-level imports of node:fs, node:path, or Buffer in shared modules. If you need them, isolate them in a file that's only imported from Node.js code. You can add the server-only package to files that must never reach the client bundle, which is a separate but related boundary.

FAQ

Will runtime = "edge" stop working? It's deprecated in Next.js 16.3 and prints a warning. It still works today, but deprecations are typically followed by removal, so migrate when you can.

Does Proxy run at the edge? Proxy runs on the Node.js runtime. Where that Node.js code is deployed depends on your hosting platform. The docs note that in optimized setups Proxy can be deployed separately from your render code, which is why it shouldn't rely on shared modules or globals.

Can I still use middleware.ts with Edge? Yes, middleware.ts is deprecated but still supported and still defaults to Edge. It's the intended escape hatch if you can't move to Node.js yet.

Are Node.js cold starts a problem? For most apps, no. Cached and prerendered pages never hit a cold function, and hosting platforms have reduced Node.js cold starts considerably. Measure on your own deployment before optimizing for it.

Conclusion

The Edge runtime solved a real problem, fast startup close to users, but it came with a reduced API surface, library headaches, and no access to the caching features Next.js now relies on. In Next.js 16 the choice is effectively made for you: render on Node.js, use proxy.ts (also Node.js) for request-level logic, and get speed from static output, "use cache", streaming, and running compute near your data. If you still have Edge routes, removing the runtime export is often the entire migration. Where it isn't, the work is replacing edge-specific packages with their standard Node.js versions.

Tags :
Share :

Related Posts

A Deep Dive into next.config Options Every Developer Should Know

A Deep Dive into next.config Options Every Developer Should Know

next.config.ts is the one file every Next.js project has and almost nobody reads end to end. It starts as an empty object, then slowly collects a r

Continue Reading
Adding JSON-LD Structured Data to Next.js Pages for Rich Search Results

Adding JSON-LD Structured Data to Next.js Pages for Rich Search Results

Search engines are good at reading pages, but they still guess. Is "4.7" a rating or a version number? Is that date when the article was published or

Continue Reading
Adding Page Transitions and Animations to Next.js with Framer Motion

Adding Page Transitions and Animations to Next.js with Framer Motion

Animation is one of the easiest ways to make an app feel polished, and one of the easiest ways to make it feel slow. A subtle fade when a page loads,

Continue Reading