In Next.js 16 the middleware file is called proxy.ts and exports a function named proxy; middleware.ts is deprecated and a codemod renames it. The function receives a NextRequest and can redirect, rewrite, set headers or cookies, or return a response. A config.matcher limits which paths run it. It now uses the Node.js runtime by default.
| Before (15 and earlier) | Now (16) |
|---|---|
| middleware.ts | proxy.ts, at the same level as app or pages |
| export function middleware() | export function proxy() or a default export |
| Edge runtime by default | Node.js runtime by default; the runtime option throws |
| skipMiddlewareUrlNormalize | skipProxyUrlNormalize |
| Manual rename | npx @next/codemod@canary middleware-to-proxy . |
Updated
Many answers online still describe middleware.ts on the Edge runtime. The examples here use the current file name, run as written, and stay short enough to adapt. Try the simulator to see which branch of a combined proxy fires for a given request before you deploy it.
Set a path, a session cookie, a country and a browser language, and see whether the examples below would pass the request through, redirect it or rewrite it.
| Pattern | Matches | Does not match |
|---|---|---|
| /about | /about, /about/team | /blog/about |
| /about/:path | /about/a | /about/a/c |
| /about/:path* | /about, /about/a/b/c | /aboutus |
| /about/:path+ | /about/a, /about/a/b | /about |
| /((?!api|_next/static|_next/image|favicon.ico).*) | Every page | API routes, build assets, the favicon |
An optimistic check: it only looks for the session cookie and remembers where the visitor was going.
// proxy.ts
import { NextResponse, type NextRequest } from "next/server";
export function proxy(request: NextRequest) {
const hasSession = request.cookies.has("session");
if (!hasSession) {
const login = new URL("/login", request.url);
login.searchParams.set("next", request.nextUrl.pathname);
return NextResponse.redirect(login);
}
return NextResponse.next();
}
export const config = { matcher: ["/dashboard/:path*", "/settings/:path*"] };The visitor keeps the /pricing URL but sees the EU page. The country header shown here is the one Vercel adds; other hosts use their own header name.
import { NextResponse, type NextRequest } from "next/server";
const EU = new Set(["DE", "FR", "ES", "IT", "NL", "IE"]);
export function proxy(request: NextRequest) {
const country = request.headers.get("x-vercel-ip-country") ?? "";
if (EU.has(country)) {
return NextResponse.rewrite(new URL("/pricing/eu", request.url));
}
}
export const config = { matcher: "/pricing" };A visitor is assigned a bucket once, the cookie keeps them in it, and a rewrite serves the variant at the same URL.
import { NextResponse, type NextRequest } from "next/server";
export function proxy(request: NextRequest) {
let bucket = request.cookies.get("ab-home")?.value;
if (bucket !== "a" && bucket !== "b") bucket = Math.random() < 0.5 ? "a" : "b";
const response = NextResponse.rewrite(new URL(`/home-${bucket}`, request.url));
response.cookies.set("ab-home", bucket, { path: "/", maxAge: 60 * 60 * 24 * 30 });
return response;
}
export const config = { matcher: "/" };Paths that already start with a supported locale pass through. Everything else is redirected using the first matching Accept-Language entry.
import { NextResponse, type NextRequest } from "next/server";
const LOCALES = ["en", "es", "fr"];
export function proxy(request: NextRequest) {
const { pathname } = request.nextUrl;
if (LOCALES.some((l) => pathname === `/${l}` || pathname.startsWith(`/${l}/`))) return;
const wanted = (request.headers.get("accept-language") ?? "")
.split(",")
.map((part) => part.split(";")[0].trim().slice(0, 2).toLowerCase());
const locale = wanted.find((l) => LOCALES.includes(l)) ?? "en";
return NextResponse.redirect(new URL(`/${locale}${pathname}`, request.url));
}
export const config = { matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"] };Setting headers inside the request option makes them readable with headers() in the route, without exposing them to the browser.
import { NextResponse, type NextRequest } from "next/server";
export function proxy(request: NextRequest) {
const headers = new Headers(request.headers);
headers.set("x-pathname", request.nextUrl.pathname);
return NextResponse.next({ request: { headers } });
}
Next.js applies headers and redirects from next.config first, then runs proxy, then the beforeFiles rewrites, then real files and routes. A redirect in next.config therefore wins before your proxy code ever sees the request.
Proxy can end the request itself, by returning a redirect or a JSON response, or let it continue with NextResponse.next() while adding headers and cookies on the way through.
A redirect changes the address bar and costs the visitor an extra round trip. Use it when the URL itself should change, such as sending signed-out users to /login.
A rewrite serves a different route while the address bar stays put. It suits A/B tests and country variants, where you want both versions to share one public URL.
Proxy can run for prefetches as well as page views, so database calls here multiply quickly. Reading cookies and headers is cheap; querying a user table on every request is not.
Background work like logging can go in event.waitUntil(), which keeps the invocation alive after the response has been sent.
The name is. Version 16 deprecates the middleware file convention and renames it to proxy, with the same request and response APIs. Old middleware.ts files are expected to move over, and the official codemod renames both the file and the exported function.
The Next.js team says the word middleware got confused with Express middleware and encouraged overuse. Proxy describes a network boundary in front of the app, and they recommend it as a last resort when no other API fits.
No. Proxy defaults to Node.js, and setting the runtime segment option in a proxy file throws an error. That also means Node-only libraries now work there, which was a common complaint about the old Edge-only middleware.
Only as a first filter. The Next.js authentication guide recommends reading the session cookie in proxy for fast redirects, then verifying the session again in a data access layer, Server Actions and Route Handlers. Server Functions excluded by the matcher skip proxy entirely.
No. The platform table in the docs lists static export as unsupported, because there is no server to run it. Node servers and Docker are supported, and other hosts depend on their adapter.
Related: Next.js authentication options compared, Next.js Route Handler examples, HTTP status codes, Regex tester for matcher patterns, React Server Components.
Start for free or see all free tools. We.Inc is free to start, with no card needed. AI building is on paid plans from $20 a month.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.