One file at the project root — beside app/, or inside src/ if you use it — runs before routing. Since Next.js 16 10,514 that file is proxy.ts. Everything written before October 2025 calls it middleware.ts, a name that still works while printing a deprecation warning, so you will meet both.
The documentation is blunt about why it changed: "middleware" invited comparison with Express 24,430 middleware, a chain of functions wrapping your handlers, while this is a single function at a network boundary in front of the whole application. The rename came with a change of substance — the Node.js 2,131 runtime is now the default, replacing the Edge restrictions that ruled out most npm 2,036 packages. The runtime option is unavailable here.
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function proxy(request: NextRequest) {
const { pathname } = request.nextUrl
console.log(`[proxy] ${request.method} ${pathname}`)
if (pathname.startsWith('/legacy/catalog')) {
return NextResponse.redirect(new URL('/api/products', request.url), 308)
}
if (pathname === '/catalog') {
return NextResponse.rewrite(new URL('/api/products?tag=input', request.url))
}
const requestHeaders = new Headers(request.headers)
requestHeaders.set('x-request-id', crypto.randomUUID())
const response = NextResponse.next({ request: { headers: requestHeaders } })
response.headers.set('x-handled-by', 'proxy')
return response
}
export const config = { matcher: ['/catalog', '/legacy/:path*', '/api/:path*'] }Export the function as default or as a named proxy — one per file. It receives a NextRequest and, optionally, a NextFetchEvent whose waitUntil(promise) keeps the invocation alive while background work finishes. NextResponse gives you the four things a proxy can do: redirect, rewrite (serve a different route under the original URL), next() to continue to routing, and a bare Response to answer on the spot. The first two differ in what the client sees:
$ curl -s -i http://localhost:3000/legacy/catalog HTTP/1.1 308 Permanent Redirect location: /api/products $ curl -s -i http://localhost:3000/catalog HTTP/1.1 200 OK x-middleware-rewrite: /api/products?tag=input [proxy] GET /catalog GET /catalog 200 in 13ms (next.js: 3ms, proxy.ts: 5ms, application-code: 5ms)
The address bar still reads /catalog after a rewrite. Note the response header: x-middleware-rewrite keeps the old name in 16.3.5, and the build route table prints ƒ Proxy (Middleware) — the rename is complete in the API and only partly done in the output.
The header injection is worth studying. NextResponse.next({ request: { headers } }) rewrites the request headers your handler will see; response.headers.set(...) sets a header the client will see. Getting those backwards is the most common proxy bug. A handler echoing what it received proves the first landed:
$ curl -s http://localhost:3000/api/whoami
{"pathname":"/api/whoami","seen":{"host":"127.0.0.1:3000","x-forwarded-for":"::ffff:127.0.0.1",
"x-forwarded-host":"127.0.0.1:3000","x-forwarded-port":"3000","x-forwarded-proto":"http",
"x-handled-by":"proxy","x-request-id":"6806ba6a-c58f-419d-a6b9-ce3c466a163e"}}x-request-id is the one the proxy added, now readable by every handler and Server Component through headers() — the standard way to thread a correlation id through a request. The x-forwarded-* headers came from the dev server. Migration is mechanical: 16.3.5 still runs a middleware.ts, warning at startup and naming the fix, npx @next/codemod@canary middleware-to-proxy ..