Skip to main content
ASoc
Tutorial

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.

The ASoc Team11 min read

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

  1. git mv src/middleware.ts src/proxy.ts — move, do not copy, or the build fails on both files being present.
  2. 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.
  3. Delete any export const runtime = 'edge' and audit whatever you wrote to work around Edge restrictions — Node APIs are available now.
  4. Check config.matcher exists. If it does not, you are running on every asset request.
  5. Build and read the route table. Nothing should have turned dynamic.
  6. 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

MistakeSymptomFix
Renamed the file, kept export function middlewareSilent no-op in dev (logged once); E903 thrown in production buildExport proxy, or a default export
Left middleware.ts alongside proxy.tsBuild fails naming both filesMove the file; there is no precedence rule
No config.matcherInvocation on every asset and crawler hitExclude _next/*, image extensions, sitemap.xml, robots.txt
Unguarded process.env.X! in the proxySite-wide failure when the env is unset, marketing pages includedBail out early and return NextResponse.next()
getSession() instead of getClaims()Unverified cookie treated as a sessiongetClaims() validates the JWT
Not rebuilding the response inside setAllUsers logged out at seemingly random intervalsReassign response when cookies rotate
Authorisation checks in the proxyA path the matcher misses is an unguarded pathEnforce at the data layer; the proxy is plumbing
Assuming Edge-runtime constraints still applyDefensive code and polyfills you no longer needThe 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.

Keep reading

Tutorial11 min read

A/B Testing in Next.js 16: The Proxy Recipe Costs the CDN, Not SSG

Rewriting in middleware does not make your pages dynamic — both variants stay prerendered. What it really costs is the shared cache and every request's critical path.

Read more
Tutorial11 min read

An Accessible Mega Menu in Next.js Without a Headless UI Library

It is a navigation landmark, not an application menu — and the ARIA menu pattern most tutorials copy removes your nav from every screen reader's link list. Six behaviours, eighty lines.

Read more
Tutorial12 min read

How to Build an Admin Dashboard with Next.js 16 and Tailwind CSS v4

A working admin dashboard in Next.js 16 and Tailwind CSS v4 — App Router layouts, a CSS-first theme, an accessible sidebar, and the server/client split that keeps it fast.

Read more