Next.js 16: migrating middleware.ts to proxy.ts
Next 16 quietly renamed middleware.ts to proxy.ts. Here is the migration write-up, the conceptual shift the rename encodes, and the matcher pattern almost everyone gets wrong.
the rename that broke nothing and everything
Next.js 16 quietly renamed middleware.ts to proxy.ts. The old name still works (with a deprecation warning); the new name is the recommended path. If you're upgrading an existing app, the migration is one file rename and a few mental model adjustments.
This post is the migration write-up I wish I'd had.
why the rename happened
middleware.ts was always a slightly misleading name. It implied something request-pipeline-like — wrap each request, do auth, log, return. That's true for Edge Middleware in some setups, but it didn't capture what the file actually was: a route-level interception point that runs on the Edge runtime, before the route handler resolves.
proxy.ts is more honest. It's a proxy in the architectural sense: a thing that intercepts a request, can rewrite or redirect it, can short-circuit with a response, but is not the response handler.
The Next.js team made the rename in 16 because the new "proxy" mental model maps more cleanly onto how App Router thinks about routing — and because they want to encourage users not to confuse Edge Middleware with server-side handlers (which behave differently in production).
the file move
The literal change:
git mv src/middleware.ts src/proxy.tsThat's it. The exports stay the same:
import { NextRequest, NextResponse } from 'next/server';
export function proxy(request: NextRequest) {
// identical to the old middleware function body
}
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};Note one detail: the named export is proxy, not middleware. If you migrate the file but forget to rename the export, Next.js falls back to the old behavior with a deprecation warning. Annoying but recoverable.
what stays the same
Everything inside the function:
NextRequestandNextResponse— same imports, same APIs.request.cookies,request.headers,request.nextUrl— same surface.NextResponse.redirect(),NextResponse.rewrite(),NextResponse.next()— same.- The
config.matcher— same syntax, same matching rules. - Edge runtime constraints — same. Still no Node APIs, no
fs, noprocess.envreads at request time (they get inlined at build).
If your existing middleware does auth or i18n routing, the body of the function does not change. Only the file name and the export name do.
what changes subtly
Three things to know:
Logging is silent now by default. Old middleware logged a request hit at the Edge in dev mode. The new proxy doesn't unless you opt in. If you were relying on those logs to debug, add explicit console.log in the proxy body during dev.
Error semantics are stricter. A thrown error in old middleware would render the Next.js error page. In the new proxy, an unhandled throw becomes a 500 response with no body. You'll want to wrap the body in try/catch and return an explicit NextResponse for failures — which you should have been doing anyway.
The build output names changed. .next/server/middleware.js is now .next/server/proxy.js. If you have build tooling that introspects the build output (rare, but I've seen it for monorepo build caching), update the path.
the conceptual shift
The bigger change isn't the rename — it's how you should think about what runs in this file.
Old mental model: "this is the gatekeeper for every request." That was technically right but encouraged people to put more and more logic into middleware over time. I've seen middleware files do auth + i18n + feature flags + A/B testing + analytics tagging + rate limiting + request enrichment, all in one function. Most of that is wrong.
New mental model: "this is a proxy, and a proxy should do one thing per concern." Auth in proxy.ts is fine. i18n routing in proxy.ts is fine. Both in proxy.ts is fine. But feature flags, A/B testing, and analytics tagging belong in route handlers or in client code — not at the proxy layer where they cost an Edge invocation per request.
The rename is a nudge toward this clarity. Use it.
what proxy.ts is good at
The clear use cases:
Auth gating. Read a session cookie, redirect to /login if missing, otherwise pass through.
export function proxy(request: NextRequest) {
const session = request.cookies.get('session');
if (!session && !request.nextUrl.pathname.startsWith('/login')) {
return NextResponse.redirect(new URL('/login', request.url));
}
return NextResponse.next();
}i18n routing. Inspect the path, redirect to a locale-prefixed version if needed.
Geo-aware redirects. Use request.geo to send users to a regional version of your app.
Header rewriting. Strip cookies before they hit a cache layer, add a request ID for tracing, etc.
what proxy.ts is bad at
Things to keep out of proxy.ts:
Anything that needs your database. Edge runtime is fast because it doesn't have your database connection. Doing a Postgres query in proxy.ts means every request pays the cold-start cost of a connection — usually disastrous.
Anything that needs Node APIs. No fs.readFile, no crypto (use the Web Crypto API), no Buffer. If you need these, the proxy is the wrong place.
Heavy computation. The proxy runs on every request that matches the matcher. CPU work here multiplies your Edge bill linearly with traffic.
Feature flags. Read these in route handlers or in client components. Putting them in the proxy means every request hits your flag service, which is rarely what you want.
the matcher pattern that almost everyone gets wrong
The default matcher most starters ship is:
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};This matches everything except API routes and Next's internal static assets. It's almost right.
What it misses: image and audio files served from /public. If you have /public/images/foo.jpg or /public/audio/track.mp3, the proxy runs on every request to those static files. That's wasted Edge invocations.
The corrected pattern:
export const config = {
matcher: [
'/((?!api|_next/static|_next/image|favicon.ico|images|audio).*)',
],
};Adjust the exclusion list to match your /public subdirectories.
For a stricter matcher that only fires on specific routes, list them:
export const config = {
matcher: ['/dashboard/:path*', '/account/:path*'],
};This is much cheaper than the negation pattern because it only invokes the proxy on paths that actually need it.
the testing story
Testing proxy.ts is harder than testing route handlers because the proxy runs in the Edge runtime, not Node. Vitest can't directly run Edge code.
Two paths:
Unit-test the logic, not the proxy. Pull the body of the proxy into a pure function that takes NextRequest and returns NextResponse | null. Test the function. Wire the proxy as a thin shim that calls it.
// proxy.ts — thin shim
import { proxyLogic } from './lib/proxy-logic';
export function proxy(request: NextRequest) {
return proxyLogic(request) ?? NextResponse.next();
}
// proxy-logic.test.ts — testable
import { proxyLogic } from './lib/proxy-logic';
describe('proxy logic', () => {
it('redirects unauthenticated users', () => {
const req = new NextRequest(new URL('https://x.com/dashboard'));
expect(proxyLogic(req)?.headers.get('location')).toContain('/login');
});
});Integration-test via Playwright. Boot the dev server, hit the routes, assert on the response. Slower but covers the actual runtime behavior.
I do both. Unit tests cover the decisions; integration tests cover the wiring.
the migration checklist
For an existing Next.js 15 project upgrading to 16:
git mv src/middleware.ts src/proxy.ts- Rename the exported function from
middlewaretoproxy. - Run the dev server, watch for deprecation warnings.
- Audit the matcher — does it exclude all of
/publiccorrectly? - Move logic that doesn't belong in the proxy out of it (feature flags, analytics, anything DB-dependent).
- Update CI scripts that reference
middleware.jsin the build output. - Update internal docs and onboarding guides.
Total time for most projects: under an hour. Most of that is the audit, not the rename.
the meta-point
Renames in major frameworks usually expose conceptual cleanups that the API was supposed to encourage. The middleware → proxy rename is one of these. It's a small change that nudges teams toward smaller, more focused interception logic — which is what the file should have been doing all along.
If you have a 200-line middleware.ts with five concerns in it, the rename is the perfect excuse to break it up.