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.ts exists at project root
  • Exported function is named proxy
  • middleware.ts is deleted (otherwise it compiles but does nothing)
  • next.config.js uses skipProxyUrlNormalize instead of skipMiddlewareUrlNormalize

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

  1. Run the codemod and manually verify proxy.ts, export name, and deleted middleware.ts.
  2. Add a matcher regex that excludes static files.
  3. Use getVerifiedUser() in every protected Server Component instead of trusting headers.
  4. Test with a route not covered by matcher to ensure auth still holds.