The Rename That Matters
Next.js 16 renames middleware.ts to proxy.ts. The Next.js team did this because "middleware" implied a general-purpose pipeline like Express, but the file actually sits at the network boundary, intercepting requests before they reach routes. The official docs state: "Proxy is meant to be invoked separately of your render code."
Runtime Change: Node.js by Default
middleware.ts defaulted to Edge runtime with limited crypto support. proxy.ts runs on Node.js by default (not configurable). Full jose support. No workarounds for JWT verification.
Migration Steps
Run the codemod:
npx @next/codemod@canary upgrade latest
# or for only the middleware migration:
npx @next/codemod@canary middleware-to-proxy .
Then manually verify:
proxy.tsexists at project root- Exported function is named
proxy middleware.tsis deleted (otherwise it compiles but does nothing)next.config.jsusesskipProxyUrlNormalizeinstead ofskipMiddlewareUrlNormalize
Three Decisions That Break Auth
1. The Matcher
Without a matcher, proxy runs on every request—including static assets. Unauthenticated users get redirected to login on CSS requests, causing infinite redirect loops.
Correct matcher:
export const config = {
matcher: [
"/((?!_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt|.*\\.png$|.*\\.jpg$|.*\\.webp$|.*\\.svg$|.*\\.ico$).*)",
],
}
Matcher values must be constants—dynamic values are silently ignored. Also, _next/data routes are always protected even if excluded in matcher.
2. Header Direction
Setting headers on the response sends them to the browser, not to Server Components. Correct:
return NextResponse.next({
request: { headers: requestHeaders },
})
Wrong (no error):
return NextResponse.next({
headers: requestHeaders,
})
3. The try/catch
Catch block handles three failures (malformed JSON, broken JWT, expired token) with one redirect to login. No information leak about which failed.
The Header Trust Boundary
When the matcher has a gap, proxy doesn't run. A client can send a spoofed x-user-id header. Server Components can't distinguish proxy-set headers from client-sent ones. Fix: verify JWT directly in Server Component.
// lib/auth-server.ts
import { cookies } from "next/headers"
import { jwtVerify } from "jose"
const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET!)
export async function getVerifiedUser() {
const cookieStore = await cookies()
const tokenCookie = cookieStore.get("auth_tokens")?.value
if (!tokenCookie) return null
try {
const tokens = JSON.parse(tokenCookie)
const { payload } = await jwtVerify(tokens.accessToken, JWT_SECRET)
return {
userId: payload.sub as string,
role: payload.role as string,
email: (payload.email as string) ?? "",
}
} catch {
return null
}
}
Use in Server Component:
// app/dashboard/billing/page.tsx
import { redirect } from "next/navigation"
import { getVerifiedUser } from "@/lib/auth-server"
export default async function BillingPage() {
const user = await getVerifiedUser()
if (!user) redirect("/login")
// ...
}
This verifies JWT twice (proxy + Server Component). The proxy is the fast gate; the Server Component removes trust dependency on proxy having run. On routes with matcher gaps, this closes the hole.
Next Steps
- Run the codemod and manually verify proxy.ts, export name, and deleted middleware.ts.
- Add a matcher regex that excludes static files.
- Use
getVerifiedUser()in every protected Server Component instead of trusting headers. - Test with a route not covered by matcher to ensure auth still holds.


