Next.js 16 Renamed Middleware to Proxy: What Changes and What Breaks
Renaming the file is a third of the migration. The exported function has to change too, keeping both files is a build error, and the proxy runs on Node rather than the Edge.
Next.js 16 renames the middleware file convention to proxy. middleware.ts still works and logs a deprecation warning; proxy.ts is the new name. The rename is not cosmetic — the proxy file is registered on the Node.js runtime, the exported function has to be renamed too, and keeping both files is a build error.
We migrated this storefront's session-refresh middleware to proxy.ts on Next 16.2.9. Everything below is read out of the installed package rather than remembered from a changelog, because most of it is enforced by code that produces no output until it fails.
The three rules the compiler actually enforces
1. The old filename warns
next/dist/lib/constants.js now carries both names:
const MIDDLEWARE_FILENAME = 'middleware';
const MIDDLEWARE_LOCATION_REGEXP = `(?:src/)?${MIDDLEWARE_FILENAME}`;
const PROXY_FILENAME = 'proxy';
const PROXY_LOCATION_REGEXP = `(?:src/)?${PROXY_FILENAME}`;
Both are resolved. If yours is still middleware.ts, you get this once per build:
The "middleware" file convention is deprecated. Please use "proxy" instead.
Learn more: https://nextjs.org/docs/messages/middleware-to-proxy
Root or src/, either location, same as before.
2. Keeping both files is a hard error
The tempting migration is to add proxy.ts and leave middleware.ts in place until you are sure. That does not build:
Both middleware file "./src/middleware.ts" and proxy file "./src/proxy.ts"
are detected. Please use "./src/proxy.ts" only.
There is no precedence rule to learn, which is the right design — two files that both claim every request is a coin flip nobody should have to reason about. Move the file; do not copy it.
3. The exported function name matters — and this is the one that bites
This is the rule with the highest chance of costing you an afternoon, because renaming a file feels like a complete migration.
The validation is a single line:
const hasValidExport =
hasDefaultExport ||
(isMiddleware && hasMiddlewareExport) ||
(isProxy && hasProxyExport);
So inside proxy.ts, the accepted exports are a default export or a named proxy export. A named middleware export — the thing you already have, in the file you just renamed — is not valid. Next.js anticipated exactly this, and the failure message names it first:
The file "./src/proxy.ts" must export a function, either as a default export
or as a named "proxy" export.
This function is what Next.js runs for every request handled by this proxy
(previously called middleware).
Why this happens:
- You are migrating from `middleware` to `proxy`, but haven't updated the
exported function.
Two details worth knowing. In production this throws (error code E903); in development it is logged through errorOnce, with a comment in the source explaining why — the proxy runs per request including internal _next/ routes, and erroring per request would drown the log. So in dev it scrolls past once and your proxy silently does nothing for the rest of the session.
And aliased re-exports are handled, which is useful if the function is defined elsewhere:
export { refreshSession as proxy } from "@/lib/auth/refreshSession";
The parser reads the exported name, falling back to the original for plain re-exports.
It runs on Node, not the Edge
This is the substantive change hiding behind a rename, and it is worth checking against your own assumptions.
In next/dist/build/index.js, the entry written into the functions config manifest is:
if (staticInfo.runtime === 'nodejs' || isProxyFile(page)) {
hasNodeMiddleware = true;
functionsConfigManifest.functions['/_middleware'] = {
runtime: 'nodejs',
matchers: staticInfo.middleware?.matchers ?? [
{ regexp: '^.*$', originalSource: '/:path*' },
],
};
}
Read the condition carefully. A middleware.ts gets a Node runtime if it opted in with export const runtime = 'nodejs'. A proxy.ts gets it because it is a proxy file — no opt-in, no declaration.
That is the single biggest practical difference. Edge middleware runs a restricted Web-API-only runtime; plenty of Node APIs and plenty of npm packages simply do not work there, which is why so much middleware code is written defensively. On the proxy file you have Node. Cold-start and placement characteristics differ by host, so measure rather than assume the change is free — but the compatibility ceiling is gone.
Read the fallback matcher
The same snippet contains a fact worth internalising: with no config.matcher, the default is ^.*$ — literally every request.
Every static asset, every image, every _next/ chunk, every crawler hit on robots.txt. If your proxy does anything non-trivial per request, that is the difference between a handful of invocations per page view and dozens.
Ours excludes the things that need nothing:
export const config = {
matcher: [
"/((?!_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt|.*\\.(?:svg|png|jpg|jpeg|webp|gif|ico|webmanifest|html)$).*)",
],
};
Note the crawler routes in there alongside the assets. Running a session refresh on sitemap.xml costs an invocation on every crawler hit and accomplishes nothing — and the trailing html$ exclusion covers the Search Console verification file in public/.
The whole file, after migration
Session refresh is the most common real use, so here is ours in full — a Supabase session refreshed on each request so Server Components see a valid session, with rotated auth cookies written onto the response:
import { NextResponse, type NextRequest } from "next/server";
import { createServerClient } from "@supabase/ssr";
export async function proxy(request: NextRequest) {
let response = NextResponse.next({ request });
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_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),
);
},
},
},
);
await supabase.auth.getClaims();
return response;
}
Three things in there are load-bearing and none of them is about the rename.
getClaims(), never getSession(). getSession() returns whatever is in the cookie without verifying it. getClaims() validates the JWT. In a proxy that hands a session to Server Components downstream, that distinction is the difference between a session and a claim someone made about one.
The response is rebuilt inside setAll. When Supabase rotates a token, the new cookie has to land on both the request (so the rest of this render sees it) and a fresh response (so the browser stores it). Assigning response inside the callback is not a style choice; skipping it drops rotated cookies and users get logged out at random intervals.
No page reads anything this computes. That is what keeps the site static, and it is the part most easily broken by accident — see below.
The trap that is not in the migration guide
A proxy runs on every matched request, before anything else, which means a proxy that throws takes down every page it matches. Ours reads two environment variables with non-null assertions. If they are unset, createServerClient throws, and it throws for the marketing pages too — pages with no account feature anywhere in them.
Our own repo carries the warning in capital letters, because a deploy with the auth code merged and the environment not yet provisioned is a site-wide outage rather than a broken login page.
Two ways to make that impossible, both cheap:
// 1. Bail out before constructing anything that can throw.
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
const key = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
if (!url || !key) return NextResponse.next({ request });
// 2. Never let a refresh failure become a request failure.
try {
await supabase.auth.getClaims();
} catch {
// A stale session is a login prompt. A thrown proxy is an outage.
}
The general rule: a proxy should fail open on anything that is not a security decision. Session refresh is a convenience — failing it costs a re-login. Authorisation is not, and it does not belong here at all, which is the next point.
What the new name is telling you
The rename is a hint about intent, and it is worth taking. The stated reasoning is that "middleware" collides with Express-style middleware in most people's heads, which invites a layer of application logic; "proxy" describes a network boundary in front of the app, which is what the thing actually is — rewrites, redirects, headers, cookie plumbing.
Authorisation was never safe here, and the rename makes the point harder to miss. A proxy matcher is a path pattern, and path patterns miss things: a route added later, a route reachable through a rewrite, a Server Action posting to a page you did not think of as an endpoint. Every real gate on this site is enforced where the data is — row-level security in the database, an entitlement check inside the download authorisation, and ownership resolved from the verified session inside each Server Action rather than from a parameter. The proxy refreshes a cookie. It decides nothing.
Does this make my pages dynamic?
No, and this is the question people ask immediately after migrating.
A proxy that inspects, rewrites and sets cookies leaves prerendering alone. What makes a route dynamic is a page reading a per-request value the proxy produced — a header it injected, a CSP nonce, anything request-shaped. This site prerenders 111 product pages, 7 category hubs and 46 blog posts with the proxy running on every one of their requests, because none of them reads anything from it. The full list of what silently opts a page out of static rendering is its own post; the proxy is not on it until you make it so.
Migration, in order
git mv src/middleware.ts src/proxy.ts— move, do not copy, or the build fails on both files being present.- Rename the exported function to
proxy, or make it the default export. This is the step that silently no-ops in dev if you skip it. - Delete any
export const runtime = 'edge'and audit whatever you wrote to work around Edge restrictions — Node APIs are available now. - Check
config.matcherexists. If it does not, you are running on every asset request. - Build and read the route table. Nothing should have turned dynamic.
- Verify a real session survives a refresh — a dropped rotated cookie presents as intermittent logouts, not as an error.
Mistakes and how they show up
| Mistake | Symptom | Fix |
|---|---|---|
Renamed the file, kept export function middleware | Silent no-op in dev (logged once); E903 thrown in production build | Export proxy, or a default export |
Left middleware.ts alongside proxy.ts | Build fails naming both files | Move the file; there is no precedence rule |
No config.matcher | Invocation on every asset and crawler hit | Exclude _next/*, image extensions, sitemap.xml, robots.txt |
Unguarded process.env.X! in the proxy | Site-wide failure when the env is unset, marketing pages included | Bail out early and return NextResponse.next() |
getSession() instead of getClaims() | Unverified cookie treated as a session | getClaims() validates the JWT |
Not rebuilding the response inside setAll | Users logged out at seemingly random intervals | Reassign response when cookies rotate |
| Authorisation checks in the proxy | A path the matcher misses is an unguarded path | Enforce at the data layer; the proxy is plumbing |
| Assuming Edge-runtime constraints still apply | Defensive code and polyfills you no longer need | The proxy file is registered as runtime: 'nodejs' |
Frequently asked questions
Do I have to migrate now?
No. middleware.ts resolves and runs; it warns. Since the migration is a file rename, an export rename and a matcher audit, doing it while you still remember what the file does is cheaper than doing it under a future major.
Can I keep the Edge runtime?
The proxy file is registered with runtime: 'nodejs' by the build, unconditionally. If Edge placement is genuinely what you need for a piece of work — geolocation-based rewrites, say — that is worth verifying against your host's current behaviour before you migrate, because it is the one thing the rename changes underneath you.
Does proxy.ts work in the Pages Router?
The file convention is resolved at the project level, root or src/, independent of router. The rest of what you can do inside it is unchanged.
Is there a codemod?
Yes — npx @next/codemod@canary middleware-to-proxy renames the file and the exported function, which is the step most likely to be missed by hand. It does not audit your matcher or your error handling, so steps 4 through 6 above are still yours. The canonical page is nextjs.org/docs/messages/middleware-to-proxy, which every one of the error messages above links to.
Should the proxy read the database? Prefer not to. It runs on every matched request, so any latency you add there is added to every page load, and the failure mode is site-wide rather than page-local. Refresh a token, rewrite a path, set a header — then get out of the way.
Templates with the auth plumbing already wired
The proxy is the least interesting file in an application and one of the easiest to get subtly wrong — the failure modes are intermittent logouts and outages rather than errors. The templates below ship a product-shaped marketing funnel with the session and account scaffolding already in place, so this file is one you inherit rather than one you debug.
