Next.js 16 renamed middleware.ts to proxy.ts. The official codemod handles the file rename and the function signature, but I hit three things on a real production migration that the changelog glosses over. Writing them down here so the next person searching for "proxy.ts not firing" finds something useful.
Migrating middleware.ts to proxy.ts in Next.js 16: the parts the codemod misses
Next.js 16 renamed middleware.ts to proxy.ts. The official codemod handles the file rename and the function signature, but I hit three things on a real production migration that the changelog glosses over.

The rename isn't just cosmetic. Three behaviors changed underneath:
- Default runtime flipped from Edge to Node. This is the big one.
- Matcher regex semantics for catch-all segments are stricter.
NextResponse.next()with header mutation is now the recommended path instead of returningundefined.
1. The runtime default flip
Old:
// middleware.ts (Next.js 15)
import { NextResponse } from 'next/server'
export function middleware(req: NextRequest) {
return NextResponse.next()
}
// runtime defaulted to edgeNew:
// proxy.ts (Next.js 16)
import { NextResponse } from 'next/server'
export function proxy(req: NextRequest) {
return NextResponse.next()
}
// runtime defaults to nodejsIf you were relying on Edge for cold-start performance, you need to put it back explicitly:
export const runtime = 'edge'Measured on the same handler on Vercel, same region, warm pool drained:
- Node runtime: ~140ms p50 cold, ~8ms warm
- Edge runtime: ~15ms p50 cold, ~3ms warm
The Node default is fine if you need Node APIs (some auth libraries, anything pulling from node:crypto), and it's actively bad if you don't. There's no warning either way.
2. The matcher gotcha
This one cost me three hours. My old matcher was:
export const config = {
matcher: ['/((?!api|_next|.*\\..*).*)'],
}Under middleware.ts, this matched /blog/foo/bar and ran the rewrite. Under proxy.ts, the same matcher skips dynamic catch-all routes unless you're explicit:
export const config = {
matcher: [
'/((?!api|_next|.*\\..*).*)',
'/:path*',
],
}I couldn't find this in the migration guide. The symptom is that static routes rewrite fine and dynamic segments fall through to the original path with zero log output.
3. Header mutation
In 15, this worked:
export function middleware(req: NextRequest) {
const res = NextResponse.next()
res.headers.set('x-request-id', crypto.randomUUID())
return res
}In 16 it still works, but the recommended pattern is now to pass request headers through:
export function proxy(req: NextRequest) {
const requestHeaders = new Headers(req.headers)
requestHeaders.set('x-request-id', crypto.randomUUID())
return NextResponse.next({
request: { headers: requestHeaders },
})
}The difference is that the header is now visible to your RSCs via headers(), not just downstream. If you were reading request-scoped values in a Server Component, this is the pattern you want.
Migration checklist
Here's what I run through on every project now:
- Run the official codemod first:
npx @next/codemod@latest middleware-to-proxy . - Grep for
export const runtime— if missing and you want Edge, add it - Re-test every dynamic segment route, not just one happy path
- If you read headers in Server Components, switch to the
request.headerspattern - Check your
matcheragainst catch-all routes specifically
What I haven't figured out yet
The matcher behavior change feels like a regression, but it may be intentional based on the RFC. If anyone knows the rationale I'd genuinely like to hear it.
Full article with the cold-start benchmark table, the codemod output diff, and a working example repo is at nextjslaunchpad.com.
Article changelog (1)
- — Expanded with TL;DR, table of contents, or additional sections


