Next.js 16 renamed middleware.ts to proxy.ts. The official reason is naming clarity — but unpacking why the name matters reveals a decade of misuse patterns and one memorable security incident that proved the conceptual confusion had real consequences. This is part of a broader architecture question covered in how I build a production SaaS checklist — where auth belongs in the stack.
This is not a cosmetic rename. The official migration docs are explicit about why the old name was wrong: "middleware" implied Express-style capabilities that this layer never had, led developers to treat it as a security boundary it was never designed to be, and accumulated so much misuse that the team felt the name itself was misleading. The rename is a deliberate signal — reach for proxy.ts as a last resort, not as your default request-handling strategy.
Here is the full picture: what changed, why the name mattered more than most people realized, the security incident that made the conceptual confusion concrete, and how to migrate auth correctly.
What Changed in Next.js 16
The API surface change is minimal. NextRequest, NextResponse, the config matcher — all identical. The differences:
- The file is named
proxy.ts(orproxy.js) - The exported function is named
proxy, notmiddleware - The runtime is Node.js — Edge runtime is explicitly not supported
- Several
next.config.tsproperties were renamed
middleware.ts still works but logs a deprecation warning. You have a migration window.
The Node.js runtime change is the structurally meaningful one. The proxy layer now runs at origin with full access to the Node.js ecosystem. The latency tradeoff is real and worth understanding before you migrate — more on that in the trade-offs section.
The official reasons given for the rename, straight from the docs:
- Naming confusion with Express.js middleware — developers expected full Node.js capabilities and got a stripped-down Edge environment instead
- Intent signal — the team wants this feature used as a last resort; the name "middleware" implied it was the standard interception point for all request logic
-
Clarifying the actual role — a proxy sits at a network boundary in front of the app; that is what
proxy.tsdoes -
Future direction — the team is actively working on better APIs so that most goals currently achieved in
proxy.tscan be accomplished closer to the data, without needing the proxy layer at all
Why "Middleware" Was the Wrong Name All Along
The confusion did not start with a CVE. It started with a word.
Express.js popularized "middleware" as a concept: functions that intercept and process requests in sequence, running in the same Node.js process as your application, with full access to whatever npm packages you need. When Next.js introduced middleware.ts in Next.js 12, it borrowed the term for a structurally different thing: a thin interception layer running on the Edge runtime at CDN nodes, with a 25ms CPU time limit and no native Node.js modules.
Developers familiar with Express naturally expected Express-like behavior. They discovered the mismatch when they tried to import jsonwebtoken — which uses Node.js crypto APIs — and got a runtime error. The correct alternative was jose, a Web Crypto API-based library designed for edge environments. The error message was not helpful. The lesson — that this was not Express middleware — was learned the hard way by a lot of teams.
GitHub discussions #46722 and #71727 tracked years of demand for Node.js runtime access in middleware. The community wanted to use real libraries. The framework could not provide them without abandoning the Edge performance model. The tension was never resolved — it was dissolved by moving the runtime to Node.js and acknowledging in the name that this is not general-purpose middleware.
The second problem was overloading. In practice, middleware.ts became a catch-all: auth redirects, A/B testing, locale routing, logging, rate limiting, canary deployments, bot detection — all in one file with strict runtime limits. The more concerns you pile into a single network interception layer, the harder it is to reason about what any given request will actually do.
CVE-2025-29927: Exhibit A
In March 2025, a vulnerability was disclosed that crystallized the naming problem into something concrete.
Next.js used the x-middleware-subrequest header internally to track recursive middleware invocations and prevent infinite loops. If the header was already set when a request arrived, Next.js assumed the middleware had already run and skipped execution. An external attacker could set that header on any incoming request. Next.js would see it, conclude middleware had already executed, and serve the response without running any middleware code at all.
x-middleware-subrequest: middleware
Every application running Next.js 11.1.4 through 15.2.2 with authentication logic in middleware.ts was potentially vulnerable. Auth checks: skipped. Geo-blocking: skipped. Admin-only redirects: skipped. The UK National Cyber Security Centre issued a public advisory. Proof-of-concept exploits were circulating within days.
The patch was straightforward — validate the header's origin before trusting it. But the vulnerability exposed something that had been structurally true before the patch existed: middleware was never a reliable security boundary. The x-middleware-subrequest header was just the specific mechanism. The deeper issue was architectural. A layer designed for lightweight request interception was being used as the primary enforcement point for access control.
This is exactly the kind of failure mode the naming confusion produces. If you think you have "middleware" in the Express sense — something that runs reliably in your application's trust boundary — you will trust it as a security layer. If you think of it as a network proxy, you will not. The CVE did not change the architecture; it illustrated why the architecture had been misunderstood.
A second vulnerability, CVE-2025-66478, followed in December 2025 — a remote code execution issue that further eroded confidence in the security model of the layer.
The Codemod
The fastest path to migration is the official codemod:
npx @next/codemod@latest upgrade 16
The codemod handles four things:
- Renames
middleware.ts→proxy.ts - Renames the exported function from
middlewaretoproxy - Renames
next.config.tsproperties:-
skipMiddlewareUrlNormalize→skipProxyUrlNormalize -
experimental.middlewarePrefetch→experimental.proxyPrefetch -
experimental.middlewareClientMaxBodySize→experimental.proxyClientMaxBodySize -
experimental.externalMiddlewareRewritesResolve→experimental.externalProxyRewritesResolve
-
One known edge case: GitHub issue #86478 documents a scenario where the codemod skips the file rename when it determines no content changes are needed. If your proxy.ts does not appear after running the codemod, rename the file manually.
Verify the output, run your tests, and proceed to the auth migration below — because the codemod does not touch your auth logic.
Manual Migration
The mechanical part of manual migration is a straightforward rename:
Before (middleware.ts):
import { NextRequest, NextResponse } from "next/server";
export function middleware(request: NextRequest) {
return NextResponse.next();
}
export const config = {
matcher: ["/dashboard/:path*"],
};
After (proxy.ts):
import { NextRequest, NextResponse } from "next/server";
export function proxy(request: NextRequest) {
return NextResponse.next();
}
export const config = {
matcher: ["/dashboard/:path*"],
};
Two characters changed in the function name. One filename changed. Everything else is identical.
If you have any of the renamed next.config.ts properties, update those as well. The TypeScript types will flag them as errors if you miss any.
The Auth Migration — Where Most Teams Get It Wrong
slug="rescue-projects"
text="Inherited a Next.js codebase with middleware-based auth or security patterns that rely on the proxy layer? I audit, identify the exposure, and migrate to a proper Data Access Layer."
/>
This is the section that actually matters. The mechanical rename is trivial. Getting auth right in the new model requires rethinking where validation belongs.
The old pattern looked something like this:
// middleware.ts — the vulnerable approach
import { NextRequest, NextResponse } from "next/server";
import { jwtVerify } from "jose";
const SECRET = new TextEncoder().encode(process.env.JWT_SECRET!);
export async function middleware(request: NextRequest) {
const token = request.cookies.get("session")?.value;
if (!token) {
return NextResponse.redirect(new URL("/login", request.url));
}
try {
await jwtVerify(token, SECRET);
return NextResponse.next();
} catch {
return NextResponse.redirect(new URL("/login", request.url));
}
}
export const config = {
matcher: ["/dashboard/:path*"],
};
This code did JWT verification in middleware. It used jose because jsonwebtoken did not work on the Edge runtime. It looked correct. It was the approach recommended in multiple Auth.js tutorials and Next.js blog posts.
The problem: this pattern is exactly what CVE-2025-29927 made irrelevant. An attacker with the right header never reached the jwtVerify call. The middleware was skipped entirely.
The deeper issue: even without the CVE, middleware-as-sole-security-boundary is architecturally wrong. If a bug in your routing logic accidentally serves a protected page without going through the matcher, the auth layer has no coverage there. If you add a new route and forget to update the matcher pattern, that route is unprotected. Security that depends on a routing config staying in sync with your actual routes is security waiting to fail.
The correct pattern in proxy.ts is an optimistic cookie existence check — not validation:
// proxy.ts — correct approach
import { NextRequest, NextResponse } from "next/server";
export function proxy(request: NextRequest) {
const sessionCookie = request.cookies.get("session");
if (!sessionCookie) {
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*", "/api/private/:path*"],
};
Notice what this does not do: it does not verify the JWT signature. It does not decode the token. It does not check expiry. It checks one thing: does a cookie with the session name exist? If no cookie, redirect to login. If the cookie exists, let the request through and let the actual validation happen downstream.
This is not a security hole. It is a correct division of responsibility. The proxy layer handles the user experience concern — redirecting unauthenticated visitors to the login page before they see a flash of protected content. The actual security enforcement happens in a Data Access Layer that every Server Component and Route Handler calls:
// lib/auth.ts — server-only, the actual security boundary
import "server-only";
import { jwtVerify } from "jose";
import { cookies } from "next/headers";
import { cache } from "react";
const SECRET = new TextEncoder().encode(process.env.JWT_SECRET!);
interface Session {
userId: string;
email: string;
role: "user" | "admin";
exp: number;
}
// cache() memoizes per request — jwtVerify runs once per request, not once per component
export const getSession = cache(async (): Promise<Session | null> => {
const cookieStore = await cookies();
const token = cookieStore.get("session")?.value;
if (!token) return null;
try {
const { payload } = await jwtVerify(token, SECRET);
return payload as unknown as Session;
} catch {
return null;
}
});
export async function requireSession(): Promise<Session> {
const session = await getSession();
if (!session) {
throw new Error("Unauthorized");
}
return session;
}
Every Server Component that renders protected data calls getSession() or requireSession(). Every Route Handler that serves private API endpoints calls it. The validation is colocated with the data access — not centralized in a network-level proxy that can be bypassed or misconfigured.
// app/dashboard/page.tsx
import { requireSession } from '@/lib/auth'
import { getUserData } from '@/lib/data'
export default async function DashboardPage() {
// Throws if no valid session — caught by the nearest error boundary
const session = await requireSession()
const data = await getUserData(session.userId)
return <Dashboard user={session} data={data} />
}
// app/api/private/orders/route.ts
import { NextResponse } from "next/server";
import { getSession } from "@/lib/auth";
import { getOrders } from "@/lib/data";
export async function GET() {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const orders = await getOrders(session.userId);
return NextResponse.json(orders);
}
The proxy layer handles the redirect. The Data Access Layer handles the enforcement. Neither depends on the other being correctly configured.
What Happens With a Tampered Cookie
A common concern: if proxy.ts only checks cookie existence, what stops an attacker from sending a fake session cookie with arbitrary content?
The answer is: the Data Access Layer. getSession() calls jwtVerify, which verifies the cryptographic signature using your JWT_SECRET. A cookie containing garbage, an expired token, or a token signed with the wrong key will fail jwtVerify and return null. The user gets treated as unauthenticated and receives a 401 or an error boundary response — not access to protected data.
The proxy redirect is a UX optimization. The Data Access Layer is the enforcement mechanism. These roles do not overlap.
What proxy.ts Is Actually For
With auth correctly handled downstream, the proxy layer is free to do what it is actually good at:
Programmatic redirects based on request properties:
export function proxy(request: NextRequest) {
const { pathname } = request.nextUrl;
// Redirect legacy URLs
if (pathname.startsWith("/old-dashboard")) {
return NextResponse.redirect(
new URL(pathname.replace("/old-dashboard", "/dashboard"), request.url),
{ status: 301 }
);
}
return NextResponse.next();
}
URL rewrites for A/B testing:
export function proxy(request: NextRequest) {
const bucket = request.cookies.get("ab-bucket")?.value ?? "control";
if (request.nextUrl.pathname === "/pricing") {
return NextResponse.rewrite(new URL(`/pricing-${bucket}`, request.url));
}
return NextResponse.next();
}
Locale routing (also covered in depth in Next.js i18n at Scale):
export function proxy(request: NextRequest) {
const { pathname } = request.nextUrl;
const locale =
request.cookies.get("locale")?.value ??
request.headers.get("accept-language")?.split(",")[0]?.split("-")[0] ??
"en";
if (!pathname.startsWith(`/${locale}`)) {
return NextResponse.redirect(new URL(`/${locale}${pathname}`, request.url));
}
return NextResponse.next();
}
Modifying headers for downstream use:
export function proxy(request: NextRequest) {
const response = NextResponse.next();
// Pass request metadata downstream without re-reading headers
response.headers.set("x-request-id", crypto.randomUUID());
response.headers.set("x-pathname", request.nextUrl.pathname);
return response;
}
These are the proxy layer's strengths: thin request inspection, lightweight branching, header manipulation. Nothing that requires heavy computation, database access, or cryptographic operations. In pikkuna.fi and pi-pi.ee, locale routing is exactly what the proxy layer is used for — detecting the user's preferred language and rewriting the URL before the request reaches the application.
The Trade-offs You Should Know Before Migrating
Edge Performance Loss
The original middleware.ts ran at the CDN edge — geographically close to the user. For a user in Stockholm hitting Cloudflare's edge node in Frankfurt, middleware executed in 1–2ms of round-trip latency. Redirects were fast because they happened before the request ever reached origin.
proxy.ts runs on Node.js at origin. For the same Stockholm user, a redirect now requires a round trip to wherever your origin server sits. If your origin is in us-east-1, that is 80–100ms of added latency for every redirect.
For most applications, this is acceptable. But if you have high-traffic redirect logic — locale routing that fires on every request, aggressive A/B test bucketing — measure the latency impact before migrating. Alternatively, move that logic to Cloudflare Workers or your CDN's edge functions, which are purpose-built for it.
The Cloudflare Compatibility Bug
As of this writing, there is an open issue (#86122) where proxy.ts does not execute when the application is deployed behind Cloudflare Proxy in production. The old middleware.ts works correctly in the same setup.
This affects self-hosted deployments that use Cloudflare for DDoS protection and CDN. If you run your Next.js application on a VPS with Cloudflare in proxy mode — which is common for EU teams trying to avoid Vercel costs — test proxy.ts thoroughly in staging before cutting over. The issue is open and being tracked, but there is no timeline for resolution.
For context: the vatnode.dev setup (Turborepo monorepo, Hono API on VPS, Next.js frontend) uses Coolify for deployment and sits behind Cloudflare. This bug is directly relevant. The current workaround is to keep middleware.ts on affected deployments and wait for the upstream fix, or route proxy logic through Cloudflare Workers instead.
Third-Party Library Churn
Auth.js (formerly NextAuth), Auth0's Next.js SDK, Better Auth, and every internationalization library that hooks into middleware — all had to update their integrations when Next.js 16 landed. If you rely on any of these, check that you are on a version that supports proxy.ts before upgrading Next.js.
next-intl, for example, published a migration guide alongside the Next.js 16 release. The API change is minor, but the update is required.
The Broader Lesson
CVE-2025-29927 was a specific vulnerability with a specific patch. But the rename to proxy.ts is responding to something larger: years of ecosystem confusion about what this layer was supposed to do.
The team's own framing in the migration docs is telling: they are actively working on better APIs so that developers can achieve their goals without the proxy layer. The direction is not "use proxy.ts more confidently" — it is "use proxy.ts less, and understand exactly why you are using it when you do."
When a security boundary is defined by a routing config that can go out of sync, by a runtime that cannot run real crypto libraries, and by a name that implies capabilities it does not have — you get defensive code in the wrong place, false confidence in that defensive code, and eventually a CVE that proves the confidence was misplaced.
The architectural lesson is not specific to Next.js. It applies to any framework with a request interception layer: thin interception should not be your security boundary. The security boundary should be colocated with the data, in code that runs every time the data is accessed, under a runtime that can actually enforce access controls.
proxy.ts makes this division explicit in the name. It will not stop teams from putting auth logic in the proxy layer — the API allows it. But the name makes the intent harder to misread.
Building something on Next.js 16 that needs production-grade auth, multi-region routing, or EU compliance? These are problems I've solved across vatnode.dev, pikkuna.fi, and pi-pi.ee. If your codebase needs security-focused rescue work or technical consultation on the auth architecture specifically, I can step in either way. If you need a senior developer who can own the architecture end-to-end — get in touch.
Related reading:
- How to Build a SaaS with Next.js: Production Checklist — auth architecture, billing, and production readiness in one place
- Stripe Webhooks Done Right: Production Architecture — another security-sensitive layer where naive implementations fail
- Redis Rate Limiting in Production — protecting API endpoints with sliding window counters
- Next.js 16 release post — official announcement including the proxy.ts introduction
- Official migration reference — nextjs.org/docs/messages/middleware-to-proxy
Top comments (1)
Great article! The distinction between UX-oriented redirects in proxy.ts and actual authentication in the data access layer is a valuable takeaway for anyone building production Next.js applications.