Middleware in Next.js runs before a request reaches a route, on Vercel's edge network rather than in your normal serverless function region — which means it executes physically close to the visitor, before your app's normal request handling starts. That's the whole pitch: intercept a request cheaply and decide whether to let it through, redirect it, rewrite it, or modify it, all before any page rendering or API logic runs. The catch is that "runs on the edge" comes with a genuinely restricted runtime, and most of the mistakes I see are people writing middleware as if it were a normal Node.js request handler.
What middleware actually looks like
A middleware.ts file at your project root (or in src/) exports a single function that receives the incoming request and returns a response, a redirect, or nothing (letting the request continue unmodified). A config.matcher export controls which paths it runs on — you almost always want to scope it, since running middleware on every static asset request is wasted edge invocations.
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const session = request.cookies.get('session')?.value;
if (!session && request.nextUrl.pathname.startsWith('/dashboard')) {
const loginUrl = new URL('/login', request.url);
loginUrl.searchParams.set('from', request.nextUrl.pathname);
return NextResponse.redirect(loginUrl);
}
return NextResponse.next();
}
export const config = {
matcher: ['/dashboard/:path*', '/account/:path*'],
};
Auth redirects, A/B testing, and geolocation routing
The auth-redirect example above is the most common use case: check a cookie or token, bounce unauthenticated users before they ever hit a protected page's server component. A/B testing is the second most common — middleware can read or set a cookie that pins a visitor to a variant, then rewrite the request to /page-variant-b while the URL bar still shows /page, which keeps the experiment invisible to the user and to analytics that key off the visible path. Geolocation routing uses request.geo (on Vercel, populated from the edge network's own IP geolocation, no external API call needed) to rewrite requests to region-specific content — routing a visitor from Kenya to a localized pricing page without a client-side redirect flash.
export function middleware(request: NextRequest) {
const country = request.geo?.country ?? 'US';
if (country === 'KE' && !request.nextUrl.pathname.startsWith('/ke')) {
return NextResponse.rewrite(new URL(`/ke${request.nextUrl.pathname}`, request.url));
}
return NextResponse.next();
}
A redirect sends the browser a 3xx and a new URL — the visible URL changes. A rewrite serves different content at the same visible URL, transparently on the server side. A/B tests and geo-routing almost always want a rewrite; only use a redirect when you want the user to see the URL change, like an auth bounce to /login.
The edge runtime's real constraints
Edge Middleware doesn't run in Node.js — it runs in a V8-isolate-based runtime closer to what Cloudflare Workers use. That means no Node built-ins like fs, no arbitrary native modules, and no long-running work. Most Node-specific npm packages that touch the filesystem, spawn child processes, or depend on Node's crypto module in non-Web-standard ways will fail to build or run. The Web Crypto API, fetch, and standard Web APIs work fine.
Edge Middleware has to be fast — it runs on every matched request before your page even starts rendering, so Vercel enforces tight CPU-time limits (low milliseconds under normal load) and a size limit on the compiled middleware bundle. Don't call a slow third-party API from middleware on every request; if you need that, cache the result or move the check to a route handler instead.
What doesn't belong in middleware
Database queries, anything requiring a persistent connection pool, and heavy computation are the wrong fit — middleware instances are short-lived and distributed globally, so they don't hold connections the way a regional server would. If a decision genuinely needs a database round-trip (checking a feature flag stored in Postgres, say), either cache that value somewhere edge-readable (a KV store, an edge config service) or accept the latency cost and do it in middleware sparingly, on a narrowly matched path.
Wrapping up
Edge Middleware is a narrow, fast interception point, not a general request-handling layer — its value comes precisely from the constraints: no Node APIs, tight time budgets, and global distribution mean it's suited to quick decisions (redirect, rewrite, header injection) and poorly suited to anything resembling business logic. Reach for it for auth gating, A/B routing, and geolocation-based rewrites; keep database work, session creation, and anything slow in your normal route handlers where a full runtime is available.
Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.