Introduction: The Gateway to Your Next.js Application
Every request to your Next.js application passes through a gateway before it ever reaches a route handler, a server component, or a static page. That gateway is middleware — and in Next.js 16, it's been fundamentally reimagined as proxy.ts.
Middleware sits at the network boundary of your app. It intercepts incoming requests and can redirect users, rewrite URLs, set headers, manage cookies, implement rate limiting, enforce authentication, handle internationalization, run A/B tests, and return responses directly — all before your application code even starts to execute. It's quite literally the first line of defense and your first chance to shape the user experience.
And yet, middleware is one of the most misunderstood features in the entire Next.js ecosystem.
Developers either underuse it (missing out on seriously powerful patterns) or overuse it (stuffing business logic where it really doesn't belong). The critical CVE-2025-29927 vulnerability showed us exactly what happens when teams lean too heavily on middleware for security without implementing defense in depth. And the transition from middleware.ts to proxy.ts in Next.js 16 has left a lot of developers scratching their heads about what changed, why it changed, and how to actually migrate.
So, let's dive into all of it. We'll start with the fundamentals of how middleware works in the request lifecycle, walk through every practical pattern — authentication gates, i18n routing, rate limiting, A/B testing, geolocation redirects, CORS handling — examine the security lessons from CVE-2025-29927, and then break down the Next.js 16 migration to proxy.ts step by step. Whether you're writing your first middleware function or hardening an existing production app, you'll walk away with patterns you can deploy right away.
How Middleware Fits in the Request Lifecycle
Understanding where middleware executes in the Next.js request pipeline is essential for using it effectively. When a request arrives, Next.js processes it in this order:
- Headers from
next.config.js - Redirects from
next.config.js - Middleware / Proxy (your custom logic)
- beforeFiles rewrites from
next.config.js - Filesystem routes (
public/,_next/static/, pages, app routes) - afterFiles rewrites from
next.config.js - Dynamic routes (
/blog/[slug]) - Fallback rewrites from
next.config.js
This ordering matters more than you might think. Middleware runs after static redirects from your config but before any route matching happens. That means you can intercept a request, inspect its headers or cookies, and decide to redirect, rewrite, or modify it before Next.js even looks at your file system for a matching page.
Create your middleware file at the root of your project (or inside src/ if you use that convention). There can only be one middleware file per project:
// middleware.ts (Next.js 15 and earlier)
// proxy.ts (Next.js 16+)
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function middleware(request: NextRequest) {
// Your logic here
return NextResponse.next();
}
export const config = {
matcher: "/dashboard/:path*",
};
The function receives a NextRequest object (an extension of the standard Request Web API) and returns a NextResponse. If you don't return a response, the request continues to the next step in the pipeline.
Matcher Configuration: Controlling Where Middleware Runs
Running middleware on every single request is wasteful. Static assets, images, and Next.js internal routes don't need authentication checks or locale detection. The matcher config lets you scope middleware to exactly the routes that actually need it.
Simple String Matchers
// Run only on the /about page
export const config = {
matcher: "/about",
};
// Run on multiple specific paths
export const config = {
matcher: ["/dashboard/:path*", "/api/:path*", "/account/:path*"],
};
Negative Lookahead Pattern
The most common pattern (and honestly, the one you'll probably use 90% of the time) is to run middleware on everything except static files and internal Next.js routes:
export const config = {
matcher: [
// Match all paths except static files and Next.js internals
"/((?!api|_next/static|_next/image|favicon\\.ico|sitemap\\.xml|robots\\.txt).*)",
],
};
Object Matchers with Conditions
For more sophisticated matching, you can use the object syntax with has and missing conditions:
export const config = {
matcher: [
{
source: "/api/:path*",
has: [
{ type: "header", key: "Authorization" },
],
},
{
source: "/dashboard/:path*",
missing: [
{ type: "cookie", key: "session" },
],
},
],
};
This config runs middleware on API routes only when an Authorization header is present, and on dashboard routes only when a session cookie is missing. This kind of conditional matching can significantly cut down on unnecessary middleware executions.
Matcher Rules to Remember
- Matchers must start with
/ - Named parameters:
/blog/:slugmatches/blog/hello-world - Modifiers:
:path*(zero or more),:path+(one or more),:path?(zero or one) - Regex in parentheses:
/blog/(.*)is equivalent to/blog/:path* - Matcher values must be constants — they're statically analyzed at build time
Pattern 1: Authentication and Authorization Gates
This is probably the most common middleware pattern out there: protecting routes behind authentication. The idea is simple — check if the user has a valid session, and redirect them to a login page if they don't.
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { jwtVerify } from "jose";
const SECRET = new TextEncoder().encode(process.env.JWT_SECRET!);
const protectedPaths = ["/dashboard", "/account", "/settings"];
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Check if this is a protected route
const isProtected = protectedPaths.some((path) =>
pathname.startsWith(path)
);
if (!isProtected) {
return NextResponse.next();
}
const token = request.cookies.get("auth-token")?.value;
if (!token) {
const loginUrl = new URL("/login", request.url);
loginUrl.searchParams.set("callbackUrl", pathname);
return NextResponse.redirect(loginUrl);
}
try {
const { payload } = await jwtVerify(token, SECRET);
// Pass user info downstream via headers
const requestHeaders = new Headers(request.headers);
requestHeaders.set("x-user-id", payload.sub as string);
requestHeaders.set("x-user-role", payload.role as string);
return NextResponse.next({
request: { headers: requestHeaders },
});
} catch {
// Token invalid or expired — redirect to login
const response = NextResponse.redirect(
new URL("/login", request.url)
);
response.cookies.delete("auth-token");
return response;
}
}
export const config = {
matcher: ["/dashboard/:path*", "/account/:path*", "/settings/:path*"],
};
We're using the jose library here for JWT verification because it works with both the Edge runtime (for middleware.ts) and the Node.js runtime (for proxy.ts). Once verified, we pass user information downstream via custom request headers so that server components and API routes can access it without needing to re-verify the token.
Role-Based Access Control
You can extend this pattern to handle role-based permissions:
const roleRequirements: Record<string, string[]> = {
"/admin": ["admin"],
"/dashboard/billing": ["admin", "billing"],
"/dashboard": ["admin", "user", "billing"],
};
// Inside your middleware function, after JWT verification:
const userRole = payload.role as string;
for (const [path, roles] of Object.entries(roleRequirements)) {
if (pathname.startsWith(path) && !roles.includes(userRole)) {
return NextResponse.redirect(new URL("/unauthorized", request.url));
}
}
Important caveat: We'll talk shortly about why middleware should never be your only layer of authentication. Always verify permissions at the data access layer too — trust me on this one.
Pattern 2: Internationalization (i18n) Routing
Middleware is honestly the ideal place to handle locale detection and routing. By intercepting requests before they reach your routes, you can transparently redirect users to locale-prefixed paths:
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { match } from "@formatjs/intl-localematcher";
import Negotiator from "negotiator";
const locales = ["en", "fr", "de", "ja"];
const defaultLocale = "en";
function getPreferredLocale(request: NextRequest): string {
const headers: Record<string, string> = {};
request.headers.forEach((value, key) => {
headers[key] = value;
});
const languages = new Negotiator({ headers }).languages();
return match(languages, locales, defaultLocale);
}
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Check if the pathname already has a locale prefix
const pathnameHasLocale = locales.some(
(locale) =>
pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
);
if (pathnameHasLocale) {
return NextResponse.next();
}
// Detect preferred locale
const locale = getPreferredLocale(request);
// Redirect to locale-prefixed path
const url = new URL(`/${locale}${pathname}`, request.url);
url.search = request.nextUrl.search;
return NextResponse.redirect(url);
}
export const config = {
matcher: [
"/((?!_next|api|favicon\\.ico|sitemap\\.xml|robots\\.txt).*)",
],
};
This middleware detects the user's preferred language from the Accept-Language header using the Negotiator library, then redirects them to the correctly prefixed URL. So someone visiting /about would get redirected to /en/about, /fr/about, or /de/about depending on their browser settings.
For production applications, I'd strongly recommend looking at next-intl. It provides a well-tested middleware configuration that handles locale negotiation, cookie-based locale persistence, and integration with the App Router out of the box.
Pattern 3: Rate Limiting at the Edge
Rate limiting in middleware prevents abuse before requests even reach your application logic. Here's a simple in-memory implementation that works well for single-server deployments:
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
const rateLimit = new Map<string, { count: number; resetTime: number }>();
const WINDOW_MS = 60 * 1000; // 1 minute
const MAX_REQUESTS = 100;
function getRateLimitKey(request: NextRequest): string {
const forwarded = request.headers.get("x-forwarded-for");
const ip = forwarded?.split(",")[0]?.trim() || "unknown";
return ip;
}
export function middleware(request: NextRequest) {
const key = getRateLimitKey(request);
const now = Date.now();
const entry = rateLimit.get(key);
if (!entry || now > entry.resetTime) {
rateLimit.set(key, { count: 1, resetTime: now + WINDOW_MS });
return NextResponse.next();
}
if (entry.count >= MAX_REQUESTS) {
return NextResponse.json(
{ error: "Too many requests. Please try again later." },
{
status: 429,
headers: {
"Retry-After": String(
Math.ceil((entry.resetTime - now) / 1000)
),
},
}
);
}
entry.count++;
return NextResponse.next();
}
export const config = {
matcher: "/api/:path*",
};
For production environments with multiple servers or serverless functions, you'll want a distributed rate limiting solution. @upstash/ratelimit with Upstash Redis is purpose-built for edge functions and supports multi-region Redis for optimal latency:
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(100, "1 m"),
analytics: true,
});
export async function middleware(request: NextRequest) {
const ip =
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
"anonymous";
const { success, limit, reset, remaining } =
await ratelimit.limit(ip);
if (!success) {
return NextResponse.json(
{ error: "Rate limit exceeded" },
{
status: 429,
headers: {
"X-RateLimit-Limit": limit.toString(),
"X-RateLimit-Remaining": remaining.toString(),
"X-RateLimit-Reset": reset.toString(),
},
}
);
}
const response = NextResponse.next();
response.headers.set("X-RateLimit-Limit", limit.toString());
response.headers.set("X-RateLimit-Remaining", remaining.toString());
return response;
}
export const config = {
matcher: "/api/:path*",
};
Pattern 4: A/B Testing Without Client-Side JavaScript
This is one of my favorite middleware patterns. You can do server-side A/B testing that works without any client-side JavaScript, eliminates content flicker entirely, and is completely invisible to the user. The technique uses URL rewrites: the user sees /pricing in their browser, but behind the scenes, middleware serves either /pricing/variant-a or /pricing/variant-b.
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
const EXPERIMENTS = {
"pricing-redesign": {
path: "/pricing",
variants: ["control", "variant-a", "variant-b"],
},
"hero-cta": {
path: "/",
variants: ["control", "variant-a"],
},
};
function getVariant(experimentId: string, variants: string[]): string {
// Simple hash-based assignment for consistent bucketing
const hash = experimentId.split("").reduce((acc, char) => {
return ((acc << 5) - acc + char.charCodeAt(0)) | 0;
}, 0);
return variants[Math.abs(hash) % variants.length];
}
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
for (const [id, experiment] of Object.entries(EXPERIMENTS)) {
if (pathname !== experiment.path) continue;
const cookieName = `exp-${id}`;
let variant = request.cookies.get(cookieName)?.value;
if (!variant || !experiment.variants.includes(variant)) {
variant =
experiment.variants[
Math.floor(Math.random() * experiment.variants.length)
];
}
// Rewrite to the variant page
const url = request.nextUrl.clone();
if (variant !== "control") {
url.pathname = `${pathname}/${variant}`;
}
const response = NextResponse.rewrite(url);
// Persist the variant assignment in a cookie
response.cookies.set(cookieName, variant, {
maxAge: 60 * 60 * 24 * 30, // 30 days
httpOnly: true,
sameSite: "lax",
});
return response;
}
return NextResponse.next();
}
Create your variant pages at /pricing/variant-a/page.tsx and /pricing/variant-b/page.tsx. The user always sees /pricing in the URL bar, but the content they receive depends on their assigned variant. The cookie makes sure they get the same variant on subsequent visits — no flicker, no layout shift, no client-side overhead.
Pattern 5: Geolocation-Based Routing
When deployed on Vercel or similar edge platforms, middleware receives geolocation data in the request object. You can use this to redirect users to region-specific content or enforce geographic restrictions:
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
const COUNTRY_STORE_MAP: Record<string, string> = {
US: "/us",
GB: "/uk",
DE: "/eu",
FR: "/eu",
JP: "/jp",
};
export function middleware(request: NextRequest) {
const country = request.geo?.country || "US";
const { pathname } = request.nextUrl;
// Skip if already on a region-specific path
const regionPrefixes = Object.values(COUNTRY_STORE_MAP);
if (regionPrefixes.some((prefix) => pathname.startsWith(prefix))) {
return NextResponse.next();
}
// Redirect to region-specific store
const storePath = COUNTRY_STORE_MAP[country] || "/us";
const url = request.nextUrl.clone();
url.pathname = `${storePath}${pathname}`;
return NextResponse.rewrite(url);
}
export const config = {
matcher: "/((?!_next|api|favicon\\.ico).*)",
};
Geolocation data is available via request.geo and includes the country code, region, city, latitude, and longitude. This gets populated by the hosting platform — on Vercel, it just works automatically. For local development, you can mock this data or use the x-forwarded-for header with a geo-IP lookup service.
Pattern 6: CORS Handling
Middleware gives you a centralized place to handle Cross-Origin Resource Sharing (CORS), which is especially useful when your API serves multiple frontend domains:
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
const allowedOrigins = [
"https://myapp.com",
"https://staging.myapp.com",
];
export function middleware(request: NextRequest) {
const origin = request.headers.get("origin") ?? "";
const isAllowed = allowedOrigins.includes(origin);
// Handle preflight requests
if (request.method === "OPTIONS") {
return new NextResponse(null, {
status: 204,
headers: {
"Access-Control-Allow-Origin": isAllowed ? origin : "",
"Access-Control-Allow-Methods":
"GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers":
"Content-Type, Authorization, X-Request-Id",
"Access-Control-Max-Age": "86400",
},
});
}
const response = NextResponse.next();
if (isAllowed) {
response.headers.set("Access-Control-Allow-Origin", origin);
response.headers.set(
"Access-Control-Allow-Methods",
"GET, POST, PUT, DELETE, OPTIONS"
);
response.headers.set(
"Access-Control-Expose-Headers",
"X-Request-Id"
);
}
return response;
}
export const config = {
matcher: "/api/:path*",
};
Composing Multiple Middleware Patterns
Here's the thing — Next.js only supports a single middleware file per project. When you need to combine multiple patterns (authentication, i18n, rate limiting, etc.), you need to compose them within a single function. Here's a clean pattern for doing exactly that:
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
type MiddlewareHandler = (
request: NextRequest
) => NextResponse | Response | undefined | Promise<NextResponse | Response | undefined>;
function composeMiddleware(...handlers: MiddlewareHandler[]) {
return async function (request: NextRequest) {
for (const handler of handlers) {
const result = await handler(request);
// If a handler returns a redirect or non-next() response, stop the chain
if (result && result.headers.get("x-middleware-next") !== "1") {
return result;
}
}
return NextResponse.next();
};
}
// Individual handler functions
function withRateLimit(request: NextRequest) {
// ... rate limiting logic
return undefined; // continue to next handler
}
function withAuth(request: NextRequest) {
const token = request.cookies.get("auth-token")?.value;
if (!token && request.nextUrl.pathname.startsWith("/dashboard")) {
return NextResponse.redirect(new URL("/login", request.url));
}
return undefined;
}
function withI18n(request: NextRequest) {
// ... locale detection and redirect logic
return undefined;
}
export const middleware = composeMiddleware(
withRateLimit,
withAuth,
withI18n
);
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon\\.ico).*)"],
};
Each handler returns undefined to continue the chain or returns a NextResponse to short-circuit (like a redirect or a 429 response). The order matters here — rate limiting runs first so that abusive requests get blocked before any authentication logic kicks in.
Security: Lessons from CVE-2025-29927
In March 2025, researchers disclosed CVE-2025-29927, a critical vulnerability in Next.js middleware that allowed attackers to bypass authentication entirely by adding a single HTTP header: x-middleware-subrequest. Let that sink in for a moment — one header, and your entire auth layer was gone.
What Happened
Next.js uses an internal header called x-middleware-subrequest to prevent middleware from processing the same request multiple times and causing infinite loops. The vulnerability existed because external clients could set this header themselves. When the header was present with the right value, Next.js would skip middleware execution entirely — bypassing any authentication, authorization, or access control checks implemented there.
The impact was devastating. Any application that relied on middleware as its sole authentication layer was completely exposed. An attacker could access admin panels, protected API routes, and user dashboards without any credentials whatsoever.
The Fix and Affected Versions
The vulnerability affected all Next.js versions prior to 12.3.5, 13.5.9, 14.2.25, and 15.2.3. The fix involved stripping the x-middleware-subrequest header from incoming external requests. If you haven't updated to a patched version yet, stop reading and go do that first.
Defense in Depth: The Real Lesson
The most important takeaway from CVE-2025-29927 isn't about the specific header — it's about architecture. Middleware should be a convenience layer, not a security boundary. Here's what defense in depth looks like in a Next.js application:
// Layer 1: Middleware (proxy.ts) — first check, convenience redirect
export function proxy(request: NextRequest) {
const token = request.cookies.get("session")?.value;
if (!token) {
return NextResponse.redirect(new URL("/login", request.url));
}
return NextResponse.next();
}
// Layer 2: Server Component — verify at the page level
// app/dashboard/page.tsx
import { verifySession } from "@/lib/auth";
import { redirect } from "next/navigation";
export default async function DashboardPage() {
const session = await verifySession();
if (!session) {
redirect("/login");
}
return <Dashboard user={session.user} />;
}
// Layer 3: Data Access Layer — verify before every query
// lib/data.ts
import { verifySession } from "@/lib/auth";
export async function getUserOrders() {
const session = await verifySession();
if (!session) {
throw new Error("Unauthorized");
}
return db.query.orders.findMany({
where: eq(orders.userId, session.user.id),
});
}
With this approach, even if middleware is completely bypassed — whether through a vulnerability, a misconfigured reverse proxy, or a deployment mistake — your data stays protected. The server component re-verifies the session, and the data access layer enforces authorization right where data is actually being retrieved.
Additional Security Hardening
- Strip internal headers at your reverse proxy: Configure Nginx, Cloudflare, or your CDN to strip
x-middleware-subrequestand similar internal headers from incoming requests - Keep dependencies updated: Subscribe to Next.js security advisories and update promptly
- Audit your middleware regularly: Review what your middleware does and make sure none of its protections are load-bearing without backup verification
- Use established auth libraries: NextAuth.js (Auth.js), Clerk, and Auth0 all implement these defense-in-depth patterns for you
The Next.js 16 Migration: middleware.ts to proxy.ts
Next.js 16 introduced a significant naming change: middleware.ts is now proxy.ts. While the change might seem cosmetic at first glance, it actually reflects a deeper architectural shift.
Why the Rename?
The term "middleware" was causing real confusion. Developers kept associating it with Express.js middleware — which has very different semantics. Express middleware can chain, modify request and response objects freely, and run arbitrary server logic. Next.js middleware operates at the network boundary: it can redirect, rewrite, set headers, and return responses, but it can't directly modify the request body or run heavy server logic.
The name "proxy" better describes what it actually does: it sits in front of your application and proxies requests, making routing decisions before the request reaches your application code. It's a reverse proxy, not an application middleware layer.
What Changed
| Before (Next.js 15) | After (Next.js 16) |
|---|---|
File: middleware.ts |
File: proxy.ts |
Export: middleware() |
Export: proxy() |
| Runtime: Edge (default) | Runtime: Node.js (only option) |
skipMiddlewareUrlNormalize |
skipProxyUrlNormalize |
The most impactful change here is the runtime. proxy.ts runs on Node.js, not the Edge runtime. This means you now have access to the full Node.js standard library — fs, crypto, path, and any Node.js-native npm packages. Those Edge runtime restrictions that forced you to use specialized libraries like jose instead of jsonwebtoken? They no longer apply.
There's a trade-off, though. Edge runtime provided ultra-low latency by running on CDN nodes close to the user. Node.js runtime runs on your server (or serverless function), which might be in a single region. For latency-sensitive operations like redirects, it's worth considering whether the added capabilities of Node.js are actually worth the potential latency increase for your specific use case.
Running the Migration Codemod
npx @next/codemod@canary middleware-to-proxy .
This codemod automatically renames the file and updates the function export name. If you'd rather migrate manually, here's what the diff looks like:
// Before: middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function middleware(request: NextRequest) {
return NextResponse.redirect(new URL("/home", request.url));
}
// After: proxy.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function proxy(request: NextRequest) {
return NextResponse.redirect(new URL("/home", request.url));
}
What About Edge Runtime?
If you still need the Edge runtime — say, because you require ultra-low latency from CDN-distributed execution — you can still use middleware.ts. It's deprecated but will continue to work. Just know that the Edge runtime isn't available in proxy.ts; the runtime config option will throw an error if used there.
The Next.js team is exploring making Node.js the default runtime starting with Next.js 17, with community feedback driving the decision.
Updating Configuration Flags
If you use configuration flags related to middleware, don't forget to update them as part of the migration:
// next.config.ts — Before
const nextConfig = {
skipMiddlewareUrlNormalize: true,
};
// next.config.ts — After
const nextConfig = {
skipProxyUrlNormalize: true,
};
Advanced: Background Tasks with waitUntil
Sometimes you need to perform work after responding to the user — logging analytics, updating a cache, firing webhooks, that sort of thing. The NextFetchEvent object provides a waitUntil() method that keeps the middleware function alive until the provided promise resolves, without delaying the response:
import { NextResponse } from "next/server";
import type { NextRequest, NextFetchEvent } from "next/server";
export function proxy(request: NextRequest, event: NextFetchEvent) {
const start = Date.now();
// Respond immediately
const response = NextResponse.next();
// Log analytics in the background
event.waitUntil(
fetch("https://analytics.example.com/events", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
path: request.nextUrl.pathname,
method: request.method,
userAgent: request.headers.get("user-agent"),
country: request.geo?.country,
duration: Date.now() - start,
timestamp: new Date().toISOString(),
}),
})
);
return response;
}
The response goes out to the user immediately, while the analytics call finishes in the background. This is perfect for non-critical side effects that shouldn't impact response latency.
Performance Best Practices
Middleware runs on every matched request. A slow middleware function delays every page load, API call, and asset request that passes through it. Here's how to keep it fast:
1. Scope Your Matcher Precisely
Don't run middleware globally when you only need it on specific routes. Every unnecessary middleware execution adds latency:
// Bad: runs on every request including static assets
export const config = {};
// Good: only runs on routes that need it
export const config = {
matcher: ["/dashboard/:path*", "/api/:path*"],
};
2. Exit Early
Check conditions and return NextResponse.next() as soon as possible. Don't do unnecessary work for requests that don't need modification:
export function proxy(request: NextRequest) {
// Exit early for requests that need no processing
if (request.nextUrl.pathname.startsWith("/public")) {
return NextResponse.next();
}
// ... more expensive logic only for other routes
}
3. Avoid Heavy Computation
Middleware is not the place for database queries, complex business logic, or heavy data processing. Keep it lightweight — validate tokens, check cookies, set headers, and redirect. Anything heavier belongs in server components, server actions, or API routes.
4. Cache Verification Results
If you're verifying JWTs on every request, the jose library is efficient, but you can optimize further by caching verified tokens in a short-lived in-memory cache keyed by the token hash. This avoids re-verifying the same token on rapid successive requests.
5. Use Conditional Responses
When you set headers or cookies but don't redirect or rewrite, always return NextResponse.next() with the modifications rather than creating a new response. This ensures the request continues through the pipeline with minimal overhead.
Common Pitfalls and How to Avoid Them
Pitfall 1: Middleware as a Business Logic Layer
Middleware should handle cross-cutting concerns: routing, authentication checks, headers, redirects. Don't put business logic there. If you find yourself querying databases or processing complex data in middleware, that's a clear sign you should move that logic to server components or API routes.
Pitfall 2: Forgetting That Middleware Runs Once Per Request
Unlike Express.js middleware, which can be stacked and chained per route, Next.js has a single middleware function. If you need different behavior for different routes, use conditional logic based on the pathname inside your single middleware function.
Pitfall 3: Not Handling the Response Correctly
If your middleware doesn't return NextResponse.next() and doesn't return a redirect or rewrite, the request may hang. Always make sure every code path returns a response — it's an easy thing to miss, especially in complex conditional logic.
Pitfall 4: Testing Middleware
Middleware can be tricky to test because it depends on the NextRequest and NextResponse APIs. The trick is to extract your logic into pure functions that take simple inputs and return simple outputs, then test those individually. Only integration-test the full middleware function against a running Next.js instance:
// lib/auth-check.ts — testable pure function
export function shouldRedirectToLogin(
pathname: string,
hasToken: boolean
): boolean {
const protectedPaths = ["/dashboard", "/account"];
return (
protectedPaths.some((p) => pathname.startsWith(p)) && !hasToken
);
}
// middleware.ts — thin wrapper
import { shouldRedirectToLogin } from "@/lib/auth-check";
export function middleware(request: NextRequest) {
const hasToken = request.cookies.has("auth-token");
if (shouldRedirectToLogin(request.nextUrl.pathname, hasToken)) {
return NextResponse.redirect(new URL("/login", request.url));
}
return NextResponse.next();
}
Conclusion: Middleware as Your Application's Front Door
Middleware — and its evolution into proxy.ts — sits at the most important boundary in your Next.js application: the point where a raw HTTP request enters your system. Used well, it gives you authentication gates, intelligent routing, rate limiting, A/B testing, and geographic personalization — all without adding a single millisecond of client-side JavaScript or a single component to your React tree.
The migration from middleware.ts to proxy.ts in Next.js 16 is more than just a rename. It signals a shift toward clarity — a proxy that runs on Node.js, with full access to the standard library, and a name that actually describes its role in the architecture. The Edge runtime sticks around for latency-critical workloads, but for most applications, the Node.js runtime gives you a better developer experience with fewer constraints.
And the lessons from CVE-2025-29927 should honestly inform every decision you make about middleware going forward. It's a convenience layer for routing and early checks, not a security perimeter. Implement defense in depth: verify at the middleware level, re-verify at the page level, and enforce authorization at the data access layer. Your users' security depends on it.
Start with the patterns in this guide, compose them thoughtfully in a single middleware function, and remember: the best middleware is the middleware you barely notice — fast, precise, and reliable.