Next.js Middleware in Production: Edge Logic That Doesn't Break Your App
Most teams write their first middleware in ten minutes and spend the next two weeks discovering why it misbehaves. Getting Next.js Middleware production ready is a different job from getting it to work locally, because middleware runs in the Edge Runtime -- a constrained environment that rejects Node.js APIs, most npm packages, and every debugging habit your team relies on. The code executes before every single request your app receives, so a mistake here is not a broken page. It is a broken application.
This post covers the patterns that survive contact with production traffic: JWT verification without Node's crypto module, matcher configuration that keeps middleware off routes it should never touch, auth redirects that do not loop, and the debugging workflow for an environment where console.log output never reaches your terminal.
What the Edge Runtime Actually Is
Next.js Middleware does not run in your Node.js server process. It runs in the Edge Runtime, a minimal JavaScript environment modeled on Web APIs rather than Node APIs. Whether it physically executes on a CDN edge node (Vercel, Cloudflare) or inside your self-hosted Next.js server, the runtime constraints are identical.
What you get: fetch, Request, Response, URL, Headers, WebCrypto (crypto.subtle), TextEncoder, and structured clone. What you do not get: fs, net, child_process, Node's crypto and Buffer (beyond a small polyfill), and any npm package that depends on them. That last category is bigger than most teams expect. Database drivers, most ORMs, jsonwebtoken, bcrypt, and many logging SDKs all fail at build time or, worse, at runtime.
The mental model that works: middleware is a request filter, not a request handler. It inspects, rewrites, redirects, and sets headers or cookies. Anything heavier belongs in a route handler or Server Component.
JWT Verification for Next.js Middleware in Production
The most common middleware job is checking whether a request carries a valid session before it reaches a protected route. The most common mistake is reaching for jsonwebtoken, which depends on Node's crypto and does not run at the edge.
The library that works is jose, which is built on WebCrypto:
import { jwtVerify } from 'jose';
const secret = new TextEncoder().encode(process.env.SESSION_SECRET);
export async function verifySession(token: string) {
try {
const { payload } = await jwtVerify(token, secret, {
algorithms: ['HS256'],
});
return payload;
} catch {
return null;
}
}
Two production notes on this pattern. First, pin the algorithm list. If you omit algorithms, you rely on the token header to declare its own algorithm, which is the root of a well-known class of JWT attacks. Second, verification in middleware should be cheap and stateless. Verify the signature and expiry, read the claims you need for routing, and stop. Checking whether the user still exists or whether the subscription is active requires a database, and the database is exactly what middleware cannot reach. Let the page or API route do that check; middleware only filters out requests that are definitely unauthenticated.
Matcher Configuration: Keep Middleware Off Routes It Should Not Touch
By default, middleware runs on every request -- pages, API routes, prefetches, images, fonts, and every file in /_next/static. Running session logic against a font file wastes compute and, on usage-billed edge platforms, costs real money. The matcher config is the fix, and its most reliable production shape is a negative lookahead:
export const config = {
matcher: [
'/((?!api|_next/static|_next/image|favicon.ico|robots.txt|sitemap.xml|.*\\.(?:png|jpg|jpeg|svg|webp|ico|woff2)).*)',
],
};
This excludes API routes, Next.js internals, and static assets in one expression. Adjust the exclusion list to your app; the principle is that middleware should run on navigations, not on asset requests.
Two constraints worth knowing before they bite. Matcher patterns must be statically analyzable -- you cannot build them from variables at runtime, because Next.js extracts them at build time. And the matcher applies to the incoming request path, not the rewritten one, so a rewrite inside middleware will not re-trigger the matcher.
If your matcher gets complicated because different route groups need different logic, keep one matcher and branch inside the middleware function on request.nextUrl.pathname. One entry point with explicit branching is much easier to audit than a subtle regex.
Auth Redirects Without Infinite Loops
The classic middleware failure is the redirect loop: unauthenticated users get sent to /login, and the middleware then runs on /login, finds no session, and redirects to /login again. Browsers stop it after a few dozen hops; your error tracking fills up long before that.
The robust pattern defines public paths explicitly and checks them before any auth logic:
const PUBLIC_PATHS = ['/login', '/register', '/forgot-password'];
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
if (PUBLIC_PATHS.some((p) => pathname.startsWith(p))) {
return NextResponse.next();
}
const token = request.cookies.get('session')?.value;
if (!token) {
const loginUrl = new URL('/login', request.url);
loginUrl.searchParams.set('from', pathname);
return NextResponse.redirect(loginUrl);
}
return NextResponse.next();
}
The from parameter matters for user experience -- after login you can return people to where they were -- but validate it before redirecting to it. Only accept relative paths that start with a single /, otherwise you have built an open redirect into your auth flow.
The second loop source is subtler: redirecting to a route that itself redirects in a Server Component or route handler. Middleware redirects and application-level redirects interact, and neither knows about the other. Keep the redirect decision in one layer. If middleware owns auth redirects, nothing else should redirect for auth reasons.
The Performance Cost Nobody Budgets For
Middleware executes before every matched request, serially, ahead of rendering. Its latency is added to your Time to First Byte on every page it touches. A fast middleware -- cookie read, JWT verify, header set -- costs one to three milliseconds. A middleware that calls fetch against an external service costs whatever that round trip costs, on every request.
That last pattern deserves a hard rule: no blocking fetch calls in middleware unless the endpoint is engineered for it. A session lookup against your backend on every request means your entire site is now as fast as that endpoint's p99. Teams do this to get "real" session validation at the edge and end up adding 80 to 300 milliseconds to every page load. If you need stronger validation than a signed JWT provides, use short token lifetimes with rotation instead of a per-request backend call.
Measure it: performance.now() works in the Edge Runtime, and logging the delta at the end of the middleware function into your edge logs gives you a latency distribution for the middleware itself, separate from rendering.
Debugging: Where Did console.log Go?
Locally, middleware logs appear in your next dev terminal and everything feels normal. In production on an edge platform, console.log output does not go to your server logs. On Vercel it lands in the per-request edge function logs; self-hosted, it goes to the Node process stdout but interleaved with everything else and missing request context.
The workflow that works in production has three parts. First, structured single-line logs: one JSON object per request with the path, the decision (next, redirect, rewrite), and the reason, so you can filter and count decisions rather than reading prose. Second, decision headers in non-production environments: setting x-middleware-decision: redirect-no-session on the response makes behavior visible in the browser DevTools network tab without any log access at all. Third, reproduce edge constraints locally with next dev plus next build; a package that only breaks in the Edge Runtime usually breaks at build time, so treat a clean production build as part of your middleware test loop.
What Middleware Cannot Do -- and What to Do Instead
The constraints, collected in one place. No database queries: no ORM, no driver, no connection pooling primitives exist in the runtime. Validate signed tokens instead, or rewrite to a route handler that does the lookup. No Node-only npm packages: check whether a dependency declares Edge Runtime support before importing it into middleware; jose, @upstash/redis (HTTP-based), and plain fetch clients work, while pg, mysql2, ioredis, and jsonwebtoken do not. No response body generation: middleware can redirect, rewrite, and set headers, but producing HTML or JSON bodies belongs in handlers. No long-running work: platforms cap middleware CPU time aggressively, and a slow middleware delays every request behind it.
None of these are arbitrary. They are what keeps the pre-request layer fast enough to run on every request. Fighting the constraints produces fragile systems; designing within them produces middleware you stop thinking about.
An Annotated Production Middleware
Here is the shape of a middleware we deploy for a Symfony-backed Next.js SaaS with subdomain tenant routing -- the Next.js frontend talks to a Symfony API, and the session cookie is a JWT issued by the backend:
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// 1. Public paths: no auth logic, no loops.
if (PUBLIC_PATHS.some((p) => pathname.startsWith(p))) {
return NextResponse.next();
}
// 2. Tenant resolution from subdomain, header-passed to the app.
const host = request.headers.get('host') ?? '';
const tenant = host.split('.')[0];
// 3. Stateless session check: signature + expiry only.
const token = request.cookies.get('wt_session')?.value;
const session = token ? await verifySession(token) : null;
if (!session) {
const loginUrl = new URL('/login', request.url);
loginUrl.searchParams.set('from', pathname);
return NextResponse.redirect(loginUrl);
}
// 4. Claims travel via request headers; pages re-check anything critical.
const headers = new Headers(request.headers);
headers.set('x-tenant', tenant);
headers.set('x-user-id', String(session.sub));
return NextResponse.next({ request: { headers } });
}
The numbered comments are the architecture: public paths first, cheap tenant resolution, stateless verification, and claims forwarded via headers so Server Components read them with headers() instead of re-parsing the cookie. The Symfony backend remains the source of truth -- middleware never decides what a user may do, only whether the request is worth rendering for.
This division of labor between a Next.js edge layer and a backend API is a recurring theme in our web application development work, and reviewing exactly this boundary -- what runs at the edge, what the backend owns -- is a standard part of our code audits. It pairs naturally with the caching layer decisions we covered in Next.js 15 caching and revalidation patterns.
Ship It Without Surprises
Middleware rewards teams that treat it as infrastructure rather than application code: small, stateless, measured, and boring. Verify signatures with jose, scope execution with a negative-lookahead matcher, define public paths before auth logic, keep blocking calls out of the hot path, and log decisions as structured data.
If your middleware has grown into something nobody wants to touch, or you are planning an auth or multi-tenant setup and want the edge layer designed right the first time, we do this for custom software builds regularly. Reach out at hello@wolf-tech.io or via wolf-tech.io -- a short review of your middleware and matcher config is usually enough to find the request path surprises before your users do.

