Next.js 16 renamed one of the framework’s oldest conventions: middleware.ts is now proxy.ts, and the exported middleware function is now proxy. Nothing about what the file can do changed. The request and response types are the same, the matcher config is the same, and rewrites, redirects, and header tweaks work exactly as before. It is purely a rename — but a deliberate one, and the reasoning behind it tells you what the Next.js team thinks this code should and should not be doing.
I went through the migration on this site, which runs a small link shortener with a Supabase-backed dashboard — the stack I walked through in Build a URL Shortener with Next.js and Supabase — and its middleware did two jobs: refresh the Supabase session cookie and gate the dashboard behind login. The mechanical rename took about two minutes. Deciding what belongs in that file took longer, and that is the more useful half of this post.
By the end you will know exactly what changed in Next.js 16 and what stayed the same, how to migrate step by step, what a migrated file looks like in a real app that does auth gating, and how to keep the proxy layer thin enough that it never becomes your slowest code path.
Why Next.js renamed middleware to proxy
The word “middleware” carries baggage. In Express and Koa, middleware means a chain of small functions living inside your application — body parsing, logging, sessions, auth — each one doing a piece of work and calling the next. If that is your mental model, then treating middleware.ts as a home for real per-request business logic feels natural. That is exactly what a lot of people did.
But Next.js middleware never worked like Express middleware. There is exactly one per application, not a chain. It runs before the router sees the request, in front of the entire app, and it historically ran in a constrained edge runtime that did not support everything Node.js does. Functionally it sits where a reverse proxy sits: it can inspect an incoming request, rewrite the URL, redirect, adjust headers or cookies — and then it must get out of the way so the application can actually respond.
The mismatch caused real problems. Because the name suggested Express, developers put database queries, full session validation, and analytics writes into middleware — code that then executed in front of every matched request on the site. The rename in the Next.js 16 release is honest labeling: this layer behaves like a proxy, so now it is called one. Same machinery, better name, clearer expectations.
What changed — and what stayed the same
The entire delta fits in a small table:
| Aspect | Before (Next.js 15) | After (Next.js 16) |
|---|---|---|
| File name | middleware.ts | proxy.ts |
| Exported function | middleware | proxy |
config.matcher | Unchanged | Unchanged |
NextRequest / NextResponse | Unchanged | Unchanged |
| Rewrites, redirects, headers, cookies | Unchanged | Unchanged |
| One file per application | Unchanged | Unchanged |
middleware.ts still works in Next.js 16 — the old name is deprecated rather than removed — but support is expected to go away in a future major version, so it is worth doing the rename as part of the upgrade rather than leaving it for someday. For the current deprecation status and the full API surface, the proxy file convention page in the Next.js docs is the source of truth.
One adjacent change is worth knowing about: around the same era, Next.js stabilized running this layer on the Node.js runtime rather than only the edge runtime, which lifts the old restrictions on Node APIs. Exactly which runtimes are available depends on your Next.js version and your hosting platform, so check the docs for your setup rather than assuming.
Step-by-step migration
Here is a minimal pre-migration file, the shape most apps have:
// middleware.ts — Next.js 15 and earlier
import { NextResponse, type NextRequest } from "next/server";
export function middleware(request: NextRequest) {
// inspect, rewrite, redirect...
return NextResponse.next();
}
export const config = {
matcher: ["/dashboard/:path*"],
};The migration is five small steps:
- Rename the file.
middleware.tsbecomesproxy.ts, in the same location — project root, or insidesrc/if that is where it lived before. - Rename the export.
export function middlewarebecomesexport function proxy. If you used a default export, that still works the same way. - Leave everything else alone. The
configexport, thematcherpatterns, and everyNextResponsecall are untouched. - Hunt for stragglers. Grep for the old file path in tests, tooling, and docs. Anything importing the middleware module directly — unit tests are the usual suspect — needs the new path.
- Verify. Make sure the old file is actually gone — you do not want both names in the tree — then run a production build and click through the routes your matcher covers.
The result, for the minimal example above:
// proxy.ts — Next.js 16
import { NextResponse, type NextRequest } from "next/server";
export function proxy(request: NextRequest) {
return NextResponse.next();
}
export const config = {
matcher: ["/dashboard/:path*"],
};The Next.js upgrade tooling around @next/codemod can handle renames like this as part of a version bump. I did it by hand, because it is two edits.
A real example: Supabase session refresh and auth gating
Minimal examples undersell what this file is for, so here is the actual proxy.ts running this site, lightly trimmed. It refreshes the Supabase auth session and gates two route groups: the dashboard pages and the link-management API.
// src/proxy.ts
import { NextResponse, type NextRequest } from "next/server";
import { updateSupabaseSession } from "@/lib/supabase/session";
export async function proxy(request: NextRequest) {
const { pathname } = request.nextUrl;
// Only refresh the session + auth-gate on protected routes.
if (
!pathname.startsWith("/dashboard") &&
!pathname.startsWith("/api/links")
) {
return NextResponse.next();
}
const { response, user } = await updateSupabaseSession(request);
if (!user && pathname.startsWith("/dashboard")) {
const url = request.nextUrl.clone();
url.pathname = "/login";
url.searchParams.set("next", pathname);
return NextResponse.redirect(url);
}
if (!user && pathname.startsWith("/api/links")) {
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
}
return response;
}
export const config = {
matcher: ["/dashboard/:path*", "/api/links/:path*"],
};Three behaviors, in order:
- Scope narrowing, twice. The
matcherkeeps the proxy off every route that does not need it — including the short-link redirect path, the hottest path on this site — and the earlyNextResponse.next()return is a second guard in the same spirit. Requests that need no auth work should pay no auth cost. - Unauthenticated page requests get redirected. The redirect to
/logincarries anextquery parameter so the login flow can send the user back where they were headed.NextResponse.redirectissues a temporary307by default, which is what you want here — auth state changes, so nothing should cache this. If the status-code menu is fuzzy, I broke it down in 301 vs 302 Redirects (and 307, 308). - Unauthenticated API requests get a 401. Redirecting an API caller to an HTML login page helps nobody; a JSON error with the right status code does.
The session refresh helper
The interesting work hides inside updateSupabaseSession, which follows the pattern from the Supabase server-side auth guide. Condensed:
// lib/supabase/session.ts (condensed)
export async function updateSupabaseSession(request: NextRequest) {
let response = NextResponse.next({ request });
const supabase = createServerClient(SUPABASE_URL, ANON_KEY, {
cookies: {
getAll: () => request.cookies.getAll(),
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value }) =>
request.cookies.set(name, value),
);
response = NextResponse.next({ request });
cookiesToSet.forEach(({ name, value, options }) =>
response.cookies.set(name, value, options),
);
},
},
});
const {
data: { user },
} = await supabase.auth.getUser();
return { response, user };
}The double cookie write looks odd but is load-bearing: refreshed auth tokens have to reach both the browser (via the response) and the server components rendering later in this same request (via the request). And the check uses supabase.auth.getUser(), which validates the token against Supabase, rather than trusting whatever the cookie claims. The full flow — callback route, redirect handling, and the gotchas — is in Magic Link Auth in Next.js with Supabase.
Keep the proxy thin
The rename is a design hint, and it is worth taking. Everything in this file runs before anything else can, on every matched request, so latency added here is added everywhere. Work that belongs in a proxy layer:
- Auth gating: check a session, redirect or reject, move on.
- Redirects and rewrites: legacy URLs, locale routing, A/B bucketing.
- Header and cookie adjustments: security headers, request IDs.
Work that does not:
- Database queries and multi-call fetch fan-out. Do it in the route handler or server component that actually needs the data — those run only for their own route, and they can stream, cache, and fail without taking the whole site down with them.
- Per-request writes like analytics or audit logs. Fire them from the route that handled the request, ideally after the response.
- Anything resembling rendering logic. If the code cares what the page looks like, it is in the wrong file.
The Supabase session refresh above is close to the ceiling of what I would accept: getUser() is a network round-trip. That is precisely why the matcher confines it to the dashboard and its API — the public site and the redirect path never pay for it.
One more framing that keeps proxies honest: the gate is a user experience feature, not your security boundary. A redirect to /login keeps signed-out users from seeing a broken dashboard, but actual data protection has to live where the data is — route handlers re-checking auth, and in Supabase’s case row level security, which I covered in Supabase Row Level Security: Practical Patterns That Scale. If the proxy were deleted tomorrow, nobody should gain access to anything.
Post-migration checklist
middleware.tsis gone; onlyproxy.tsexists, in the same directory the old file occupied.- The exported function is named
proxy(or is a default export), and nothing else in the file changed. - A production build passes — matchers are compiled at build time, so dev-mode success alone proves less than you would like.
- Protected routes still gate correctly: signed out, you are redirected with the
nextparameter intact; signed in, you pass through and the session cookie refreshes. - Unmatched routes — especially your hottest paths — never enter the proxy at all. A quick log line while testing settles this.
- Tests and tooling that imported the middleware module point at the new path.
Bottom line
The migration itself is trivial: rename the file, rename the export, change nothing else. NextRequest, NextResponse, and config.matcher all carry over untouched, and the old name keeps working in Next.js 16 while deprecated. Do the rename during the upgrade and it costs you minutes.
The lasting value is the mental model shift the name enforces. Treat proxy.ts the way you would treat rules on a reverse proxy: decide fast, redirect or rewrite or annotate, and hand off. Auth gating with a tight matcher is squarely inside that job description. Heavy logic is not — it belongs in the routes, next to the data it needs, behind a proxy that stays out of the way.