Next.js 16 deprecated the middleware file convention and renamed it to proxy. The docs are blunt about it: "The middleware file convention is deprecated and has been renamed to proxy."
Munchable's web app runs 16.2.4, so the file is proxy.ts and the export is proxy. Here it is in full, because the whole thing is 22 lines:
import { type NextRequest } from 'next/server';
import { applyCorsHeaders, corsPreflightResponse } from '@/lib/cors';
import { updateSession } from '@/lib/supabase/middleware';
// Next.js Proxy (formerly the Middleware convention). Two jobs:
// 1. CORS for /api/*: answers preflights and stamps allowed cross-origin
// responses (lib/cors.ts); route handlers never emit these themselves.
// 2. Refreshes the Supabase auth session on each matched request.
// The webhook routes are excluded: they are unauthenticated, verified by
// signature, and server-to-server (no browser, so no CORS either).
export async function proxy(request: NextRequest) {
const isApi = request.nextUrl.pathname.startsWith('/api/');
if (isApi && request.method === 'OPTIONS') return corsPreflightResponse(request);
const response = await updateSession(request);
return isApi ? applyCorsHeaders(request, response) : response;
}
export const config = {
matcher: [
'/((?!_next/static|_next/image|api/webhook|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico)$).*)',
],
};
The rename is a find and replace. The config object underneath it is where the decisions are.
The matcher is not an optimisation
The docs state the default plainly: without a matcher, the proxy runs on every request, including _next/static, image optimisation and everything in public/. So the regex is not a performance tweak bolted on afterwards, it is the difference between this function running on page requests and this function running on every font file.
Read the pattern as a single negative lookahead over the whole path:
/((?!_next/static|_next/image|api/webhook|favicon.ico|.*\.(?:svg|png|jpg|jpeg|gif|webp|ico)$).*)
Four classes of exclusion:
-
_next/staticand_next/image, the framework's own asset routes. -
api/webhook, which is the one that is about behaviour rather than cost. -
favicon.ico, the legacy path browsers ask for unprompted. - Anything ending in an image extension, anchored with
$, which catches files served frompublic/without having to enumerate directories.
The extension branch has the $ for a reason. Without it, .*\.svg matches any path that merely contains .svg, and a URL such as /is-inulin-low-fodmap?ref=logo.svg quietly stops getting a session refresh. An anchored suffix match keeps the rule about the file being requested rather than about the string.
Why the webhooks are the one route group left out
Everything else in /api/* is called by our own app with a bearer token or a cookie. The webhook routes are called by Stripe and by Resend. The difference is not a matter of degree:
-
There is no browser. CORS is a browser mechanism. A server-to-server POST sends no
Origin, triggers no preflight, and cannot read a header it was never offered. StampingAccess-Control-Allow-Originon a webhook response is decoration. - There is no session to refresh. A webhook carries no user. Running the session refresher means constructing a Supabase client and reading cookies that are not there, on every single delivery and every retry.
- Authentication is a signature, not a token. These routes verify an HMAC over the raw body. That is the whole auth story, and it belongs in the route rather than in a function that runs before it.
You can watch the exclusion from a browser. From a page on app.munchable.app, a cross-origin fetch to an ordinary API route resolves and hands back a status, which can only happen if the response carried an allow-origin header for that origin. The same fetch to a webhook path fails before it ever reaches the handler, because OPTIONS is not a safelisted method, so the browser preflights it, and nothing on that path answers preflights:
GET https://munchable.app/api/taxonomy → 401 (readable: CORS applied)
OPTIONS https://munchable.app/api/webhook/resend → TypeError: Failed to fetch
A 401 you can read is a CORS success. A TypeError is the matcher.
One thing that surprises people reading the probe: the response object shows access-control-allow-origin as null. That header is not exposed to JavaScript, ever. The proof that it was present is that the promise resolved at all.
Preflights return before anything expensive happens
The order of the two statements in the body matters more than it looks:
if (isApi && request.method === 'OPTIONS') return corsPreflightResponse(request);
const response = await updateSession(request);
A preflight is a question about policy. It has no cookies worth rotating and no user, so it returns a 204 built from headers alone and never constructs an auth client. Put the session refresh first and every API call from a browser pays for two session refreshes instead of one.
It also means route handlers never see an OPTIONS request and never need a line of CORS code. The policy lives in exactly one module. I wrote about what that policy allows separately; the relevant part here is that the proxy is the only thing that applies it.
The session refresh is deliberately cheap and deliberately fail-open
updateSession is the standard Supabase SSR pattern with two modifications:
// Wrapped so a Supabase outage or a misconfigured env refreshes nothing
// rather than failing every matched request.
try {
await verifiedClaims(supabase);
} catch {
// The request proceeds with whatever session cookies it had.
}
The first is that verifiedClaims validates the token locally against the project's public signing key. A request carrying a valid session costs no network round trip here. Only an expired token goes out to Auth, which is the one case where it must.
The second is the catch. This function runs before every page in the app, so if it throws, the site is down. Note carefully what it is failing open on: it fails open on refreshing a session, not on authorising one. Every route that needs a user resolves that user itself and returns 401 if there is not one. The worst outcome of this catch is that somebody's token is not rotated on this request and they are asked to sign in again sooner than they would have been.
The guidance we are knowingly standing next to
There is a "good to know" in the docs worth quoting:
Proxy is meant to be invoked separately of your render code and in optimized cases deployed to your CDN for fast redirect/rewrite handling, you should not attempt relying on shared modules or globals.
Our proxy imports two local modules, and one of them keeps module-level state: the allowed origin set is parsed out of environment variables once at module initialisation rather than per request.
That is on the right side of the warning, but only just, and the distinction is worth naming. The state is read-only, derived from environment variables, and identical in every instance. Nothing accumulates, nothing is cached across requests, and no request can observe another request's effect on it. The moment you want a counter, a rate limit bucket or a memo in there, you are relying on a global in a function that may be running in dozens of isolated instances at the edge, and it will be wrong in a way that looks like flakiness.
The function also costs something on every HTML request that is not excluded, and we accept that knowingly: the 373 statically generated answer pages still run a session refresh when a human requests one, because the header has to know whether you are signed in.
See it working
- app.munchable.app is the one cross-origin browser caller in the whole system, which is why the CORS half of this file exists at all.
- munchable.app/support is a page whose header changes depending on whether the session this file refreshed is valid, and whose form works whether it is or not.
- munchable.app/answers indexes the static pages mentioned above, such as is chicory root low FODMAP. Statically generated, and still matched by the regex.
If you are upgrading to Next 16, the rename is the easy part. The question worth re-asking while you are in the file is which paths you are running it on, because the default is all of them.
Top comments (0)