The Problem
I upgraded a production build to Next.js 16.2 last week. The build passed, the deploy went through, and then half the site's behaviour quietly vanished. Auth redirects stopped firing. Locale rewrites stopped matching. A preview-mode cookie check that used to gate /draft/* let everything through. The dev terminal showed this deprecation line a few times on start, then stopped mentioning it:
middleware.ts is deprecated. Rename to proxy.ts.
That one line hides three real migration traps. If your middleware still technically loads but the routes it guarded are no longer protected, this post is for you.
Why It Happens
Next.js 16 renamed the request-interception file from middleware.ts to proxy.ts. The framework still resolves the old filename for one release so the build does not crash, but it does not guarantee behaviour parity. Three things change under the hood:
1. The matcher config is stricter. Regex-style path matchers that used to silently match trailing slashes now require explicit patterns. A rule that worked on middleware.ts as '/((?!_next|api).*)' may quietly skip routes on proxy.ts.
2. The export name changed. middleware(request) is still accepted during the transition, but the canonical export is now proxy(request). If you export both, the framework warns and picks one non-deterministically across builds.
3. NextResponse helpers were partially re-scoped. NextResponse.next() still works for forwarding, but NextResponse.rewrite() in an Edge runtime now requires the runtime to be declared explicitly on proxy.ts. Without it, rewrites fall back to pass-through.
The upgrade does not fail loudly because the old file still runs. It just runs in compatibility mode, and any edge case your matcher relied on drops off.
The Fix
Work through these four steps. I keep this list as a personal checklist whenever I touch a middleware layer on a Next.js 16 upgrade.
1. Rename the file and the export
Rename middleware.ts to proxy.ts at the project root (same directory, not inside app/). Update the function name and type:
// proxy.ts
import { NextResponse, type NextRequest } from 'next/server'
export function proxy(request: NextRequest) {
const { pathname } = request.nextUrl
if (pathname.startsWith('/draft') && !request.cookies.get('preview')) {
return NextResponse.redirect(new URL('/login', request.url))
}
return NextResponse.next()
}
export const config = {
matcher: ['/draft/:path*', '/((?!_next/static|_next/image|favicon.ico|api/).*)'],
runtime: 'edge',
}
Two details that matter. The function is now called proxy, not middleware. And the runtime key belongs in the config export, not as a top-level file setting.
2. Rewrite the matcher explicitly
Matchers in 16 prefer explicit path arrays over clever negative lookaheads. If your old matcher looked like this:
export const config = {
matcher: '/((?!_next|api|.*\\.).*)',
}
Change it to a list that says what it covers, not what it excludes:
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico|robots.txt|sitemap.xml|api/).*)',
'/draft/:path*',
'/account/:path*',
],
}
The second form is longer but survives framework changes. Next.js 16.2 still supports the negative lookahead form, but the behaviour at edges (trailing slashes, case sensitivity, %20 in path segments) is tightened. Being explicit is the cheap way out.
3. Audit your NextResponse calls
NextResponse.next() still passes through. NextResponse.redirect() still issues a 307. What I see breaking is this pattern:
// Was fine in 15, silently no-ops in 16 without explicit runtime
return NextResponse.rewrite(new URL('/en' + pathname, request.url))
If you rely on rewrite() for locale routing, make sure runtime: 'edge' is declared in the config above. Also double-check that the target URL is absolute:
const url = request.nextUrl.clone()
url.pathname = '/en' + pathname
return NextResponse.rewrite(url)
Cloning request.nextUrl is more reliable than building a new URL() object because it preserves search params and base URL state in every runtime. The official Next.js proxy migration notes describe the runtime requirement if you want the canonical source.
4. Verify cookies and headers still surface
Proxy runs earlier than some pre-16 code assumed. If you read auth cookies inside a Server Component with cookies() from next/headers, that call now happens after the proxy has already seen and possibly modified the request. In practice this means:
- Cookies set inside
proxy.tsare visible to downstream Server Components on the same request - Cookies set from Server Actions still need a
revalidatePath()to be visible on the next navigation headers()read insidegenerateMetadata()will reflect whatever the proxy forwarded, not the original request
If that matters for auth, be explicit about it. Store the authenticated user ID on a forwarded request header:
const res = NextResponse.next()
res.headers.set('x-user-id', session.userId)
return res
Then read it on the route with headers(). This is more reliable than re-parsing the session cookie in three places.
If your migration also hit Server Actions trouble, I wrote up that side of the upgrade separately in my Next.js 16 Server Actions invalid error fix.
How To Confirm It Actually Works
Do not trust the dev server alone. Run the production build locally:
pnpm build && pnpm start
Then walk the three paths that matter:
- An authenticated route that requires the cookie guard
- An unauthenticated route that should redirect
- A rewritten route (locale prefix or A/B segment)
If any of those behaves like a straight pass-through, the proxy file is loaded but matcher or runtime is off. Drop a console.log('proxy hit', pathname) at the top of the function — if you do not see it for the broken route, matcher is excluding it.
The Practical Rule
Treat the middleware.ts → proxy.ts rename as a real migration, not a cosmetic renaming. The old file keeps working just long enough to lull you into shipping. Rename the file, rename the export, make the matcher explicit, declare the runtime, and verify against a production build before merging the upgrade PR.
Stuck on a Next.js 16 Upgrade?
I take on Next.js 16 migrations from 14 and 15, covering the full stack: App Router, proxy, cache semantics, Server Actions, Turbopack, and the deploy pipeline. If your upgrade branch is green on CI but quietly breaking in production, see my Next.js services and I will stabilise it.