EmpireUI
Get Pro
← Blog8 min read#edge runtime#next.js#middleware

Edge Runtime in Next.js: Middleware, Edge API Routes and Limits

Edge Runtime in Next.js gives you sub-5ms cold starts and geo-aware logic — but the constraints are real. Here's what actually works and what'll bite you.

glowing network nodes on dark abstract background server edge

What the Edge Runtime Actually Is

The Edge Runtime in Next.js isn't Node.js. That's the most important sentence in this article. It's a lightweight V8-based runtime — the same engine Chrome uses — stripped of the Node standard library and designed to start in under 5ms anywhere in Vercel's global network. Your function literally runs in the region closest to the user, not in a single datacenter you picked at deploy time.

Honestly, that geographic distribution is what makes Edge Runtime worth the pain. A user in Singapore hitting a route running in Edge doesn't wait for a cold start in us-east-1. They get a response from Singapore — or wherever PoP is closest. For auth checks, redirects, and lightweight personalization, that latency difference is real and measurable.

That said, the price you pay is a drastically smaller API surface. No fs, no native modules, no child_process, no arbitrary npm packages that touch Node internals. The runtime ships with the Web Platform APIs: fetch, Request, Response, Headers, URL, crypto, TextEncoder, ReadableStream — and that's roughly the universe you work in.

Next.js 14 (released late 2023) made Edge Runtime opt-in per-route rather than a global switch, which is the right call. You pick where to use it, not the framework.

Middleware: The Primary Use Case

Middleware is where Edge Runtime shines without compromise. Every request passes through middleware.ts before it hits a route, and because that file always runs on the edge, you get global interception for almost no latency cost. Authentication redirects, A/B routing, locale detection, bot blocking — all of this belongs here.

// middleware.ts
import { NextRequest, NextResponse } from 'next/server';

export function middleware(request: NextRequest) {
  const token = request.cookies.get('auth-token')?.value;
  const { pathname } = request.nextUrl;

  // Protected routes
  if (pathname.startsWith('/dashboard') && !token) {
    return NextResponse.redirect(new URL('/login', request.url));
  }

  // Geo-based routing
  const country = request.geo?.country ?? 'US';
  if (pathname === '/' && country === 'DE') {
    return NextResponse.rewrite(new URL('/de', request.url));
  }

  return NextResponse.next();
}

export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};

Worth noting: request.geo is a Vercel-specific injection. If you self-host Next.js on a plain Node server, geo is undefined. That's not a bug — it just means Vercel injects geo data at their edge layer before the middleware receives the request. Keep that in mind when testing locally with next dev.

The matcher config is critical for performance. Without it, your middleware runs on every single asset request — including _next/static JS chunks. The regex above skips static files and images, which cuts middleware invocations dramatically. In practice, you want to be surgical with matchers, not permissive.

One more thing — middleware can't return a full HTML page or do heavy database work. It can set cookies, rewrite URLs, redirect, and attach headers. That's it. Anything heavier belongs in an Edge API route or a server action.

Edge API Routes: How to Opt In

Standard Next.js App Router route handlers run on Node.js by default. To move one to Edge Runtime, you export a single constant: export const runtime = 'edge'. That's the entire migration.

// app/api/hello/route.ts
export const runtime = 'edge';

export async function GET(request: Request) {
  const { searchParams } = new URL(request.url);
  const name = searchParams.get('name') ?? 'world';

  return new Response(
    JSON.stringify({ message: `Hello, ${name}!` }),
    {
      headers: { 'Content-Type': 'application/json' },
    }
  );
}

The Pages Router equivalent is the same declaration but in an export const config block: export const config = { runtime: 'edge' }. Both work, but if you're on App Router (Next.js 13+), use the named export — it's cleaner and more explicit about what you're changing.

Look, the fetch-based response API takes some adjustment if you're used to the res.json() style from Express or old Next.js API routes. You're constructing raw Response objects now, which is the Web Fetch standard. It feels verbose at first but the portability is worth it — the same code would run on Cloudflare Workers with minimal changes.

Quick aside: streaming responses work especially well with Edge Runtime. You can return a ReadableStream and Vercel's edge layer will pipe it directly to the client without buffering the entire payload first. For AI-generated text or chunked data, this combination is genuinely fast.

The Hard Limits You'll Hit

Here's where people get surprised. Edge Runtime functions have a 1MB code size limit per deployment on Vercel's free and Pro tiers (as of 2026). That sounds like a lot until you try to import a validation library like zod plus a JWT library plus your own utilities. Tree-shaking helps, but you need to actively watch bundle size.

The other wall is the lack of Node.js APIs. You can't use bcrypt (native bindings), sharp (native bindings), prisma with its query engine binary, or anything that touches the file system. This rules out the standard database-client pattern. Instead, you'll reach for HTTP-based database clients — Neon's serverless driver, PlanetScale's HTTP driver, Upstash REST API for Redis — which are built specifically for this constraint.

// app/api/user/route.ts
export const runtime = 'edge';

import { neon } from '@neondatabase/serverless';

export async function GET(request: Request) {
  // neon() uses fetch under the hood — works in Edge Runtime
  const sql = neon(process.env.DATABASE_URL!);
  const [user] = await sql`SELECT id, name FROM users LIMIT 1`;

  return Response.json(user);
}

Dynamic code evaluation is disabled too — no eval(), no new Function(). If you're using a library that relies on either of these internally, it'll throw at runtime, not at build time. You'll find out in production. Run next build and check the output carefully; since Next.js 14.1, the build warns you when Edge-incompatible packages are detected.

In practice, the limit that catches teams most often is the absence of AsyncLocalStorage in older Node-style middleware patterns. Next.js 13.4+ added it back to the Edge Runtime experimentally, and it's now stable in Next.js 15 — but if you're on an older version and you're using a logging or tracing library that depends on it, expect breakage.

Choosing Between Edge and Node Runtime

The decision tree is actually pretty simple. Use Edge Runtime when your route is stateless, fast, and works with Web APIs alone — authentication checks, redirects, header manipulation, lightweight JSON transforms, geo-based logic. Use Node Runtime when you need native modules, a traditional database ORM, file system access, or any npm package that hasn't been audited for edge compatibility.

Don't default everything to Edge because the marketing copy says 'faster'. A Node.js route with connection pooling talking to Postgres via Prisma is usually faster end-to-end than an Edge function that makes three round-trip HTTP calls to a serverless DB driver — because those extra HTTP hops add latency that the Edge cold-start savings don't cover. Measure. Always measure.

// The right mental model:

// Edge Runtime — DO THIS:
// ✓ JWT verification and redirect
// ✓ A/B test cookie assignment  
// ✓ Geo-based content rewriting
// ✓ Request/response header manipulation
// ✓ Rate limiting via Upstash REST

// Node Runtime — STAY HERE:
// ✗ Prisma / Drizzle with native query engines
// ✗ Sharp image processing
// ✗ File system reads
// ✗ bcrypt password hashing
// ✗ Any package with native .node bindings

Worth noting: you can mix runtimes within the same Next.js app. Your app/api/auth/route.ts can run on Edge while app/api/images/route.ts stays on Node. There's no global configuration forcing consistency. That flexibility is underused — most teams I've talked to either go all-in on one or the other, missing the middle path that makes the most sense architecturally.

Edge Runtime and UI Performance

One underappreciated pattern is pairing Edge Middleware with UI personalization. You can read a cookie or IP-based geo data in middleware, set a custom header, and then read that header in a Server Component to render the personalized version — all without a client-side round trip. That's 0ms of personalization overhead from the user's perspective.

// middleware.ts
export function middleware(request: NextRequest) {
  const response = NextResponse.next();
  const theme = request.cookies.get('theme')?.value ?? 'dark';
  
  // Pass personalization data to Server Components via headers
  response.headers.set('x-theme', theme);
  response.headers.set('x-country', request.geo?.country ?? 'US');
  
  return response;
}
// app/layout.tsx — Server Component reads the header
import { headers } from 'next/headers';

export default function Layout({ children }: { children: React.ReactNode }) {
  const headersList = headers();
  const theme = headersList.get('x-theme') ?? 'dark';
  
  return (
    <html data-theme={theme}>
      <body>{children}</body>
    </html>
  );
}

This pattern is exactly how you'd implement a theme-aware layout on a site where themes need to apply on first load without FOUC. If you've been building design-system-heavy UIs — maybe pulling components from Empire UI where visual styles like glassmorphism or cyberpunk are first-class — edge-driven personalization lets you ship the right visual skin without any client-side hydration flash.

The combination of Edge Middleware for routing decisions and Server Components for rendering is the architecture Next.js 14+ is clearly pushing toward. It's not perfect yet — the debugging story is still rough — but the performance ceiling is genuinely higher than anything you could build with client-side state management.

Debugging and Local Development

Local development with Edge Runtime is awkward. next dev runs a Node.js process and emulates the edge environment, but it doesn't perfectly replicate the real runtime. Some packages work locally and break on Vercel. The safest debugging loop is to deploy to a preview branch early and often rather than assuming local parity.

Vercel's dashboard shows Edge Function logs in near-real-time under the Deployments tab. Filter by function name and you'll see invocations, durations, and errors. For local debugging, console.log works normally in next dev mode, but in production those logs are tied to the specific edge PoP that handled the request — meaning you might see logs from Singapore and Tokyo in the same filter view.

// Handy debug helper — only logs in development
export function edgeLog(label: string, data: unknown) {
  if (process.env.NODE_ENV === 'development') {
    console.log(`[edge:${label}]`, JSON.stringify(data, null, 2));
  }
}

For bundle size analysis, run ANALYZE=true next build with @next/bundle-analyzer configured, then look at the edge chunk specifically. The UI will show you exactly which packages landed in the edge bundle and where the size is going. This is non-negotiable once your middleware starts importing shared utilities — it's surprisingly easy to accidentally pull in 200kb of polyfills.

Building polished UIs alongside edge infrastructure is a surprisingly good pairing. When you're already reaching for a visual component library like Empire UI for things like modals, dashboards, or glassmorphism cards, having your auth and routing logic running at the edge means the HTML your Server Components return is personalized correctly on the very first byte — no layout shift, no redirect flash, no loading state for a user who was already logged in.

FAQ

Can I use Prisma in an Edge Runtime route?

Not directly — Prisma's query engine is a native binary that Edge Runtime can't execute. Use Prisma Accelerate (which proxies over HTTP) or switch to an HTTP-native client like Neon's serverless driver or Drizzle with a fetch-compatible adapter.

What's the bundle size limit for Edge Functions on Vercel?

As of 2026, Vercel enforces a 1MB compressed bundle limit per Edge Function. Exceeding it causes a deploy error, not a runtime error, so you'll catch it during next build or CI rather than in production.

Is Edge Middleware always on, or can I disable it per route?

Middleware runs based on the matcher config you export from middleware.ts. Use a precise matcher regex to exclude static assets and limit invocations to routes that actually need the middleware logic.

Does Edge Runtime work with self-hosted Next.js?

Yes, but you lose Vercel-specific features like request.geo and the global PoP distribution. On a self-hosted Node.js server, Edge routes run in the Node.js process with a compatibility layer — you still get the API constraints but not the geographic distribution benefits.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Server-Side Streaming in React + Next.js: Suspense and RSCPage Transitions in Next.js App Router: View Transitions APIVercel Edge Functions Guide: Runtime, Limits and Real-World UsesNext.js OG Image Generation: @vercel/og, Edge Runtime, Custom Fonts