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.

Next.js 16 proxy.ts Migration Guide

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.

What actually changed

The rename isn't just cosmetic. Three behaviors changed underneath:

  1. Default runtime flipped from Edge to Node. This is the big one.
  2. Matcher regex semantics for catch-all segments are stricter.
  3. NextResponse.next() with header mutation is now the recommended path instead of returning undefined.

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 edge

New:

// proxy.ts (Next.js 16)
import { NextResponse } from 'next/server'
export function proxy(req: NextRequest) {
  return NextResponse.next()
}
// runtime defaults to nodejs

If 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.headers pattern
  • Check your matcher against 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
Mei-Lin Wu
About the Author Mei-Lin Wu

Front-end architect at a SaaS. Owns the build system, the design system, and the war stories about both.