When I migrated from middleware.ts to proxy.ts after upgrading to Next.js 16, my staging environment immediately broke. Static assets — CSS bundles, JS chunks, optimized images — stopped loading. Everything returned a 302 redirect to the login page.
The cause: I copied the auth logic but did not update the matcher. Without the right matcher, proxy.js runs on every request, including the requests your browser makes to load the page itself.
What changed in Next.js 16
In Next.js 16.0.0, the middleware file convention was deprecated and renamed to proxy. Per the official docs (v16.2.9):
- File:
middleware.ts→proxy.ts(or.js) - Function:
export function middleware()→export function proxy() - Type:
NextProxy(new type, replaces inline function typing)
The codemod handles the rename:
npx @next/codemod@canary middleware-to-proxy .
The API — NextRequest, NextResponse, the config.matcher shape — is unchanged.
The matcher problem
The most common issue after migration: forgetting to scope the matcher. The docs are explicit:
"Without a matcher, Proxy runs on every request, including static files (
_next/static), image optimizations (_next/image), and assets in thepublic/folder."
So this naive proxy:
```js filename="proxy.js"
import { NextResponse } from 'next/server'
export function proxy(request) {
const session = request.cookies.get('session')
if (!session) {
return NextResponse.redirect(new URL('/login', request.url))
}
}
...will redirect unauthenticated requests for `/_next/static/chunks/main.js` to `/login`. Your page loads with no CSS, no JavaScript, and no images.
## The recommended negative matcher
Exclude paths that should never touch proxy:
```js filename="proxy.js"
import { NextResponse } from 'next/server'
export function proxy(request) {
const session = request.cookies.get('session')
if (!session) {
return NextResponse.redirect(new URL('/login', request.url))
}
return NextResponse.next()
}
export const config = {
matcher: [
/*
* Match all paths EXCEPT:
* - _next/static (JS/CSS bundles)
* - _next/image (image optimization)
* - api/ (API routes)
* - favicon, sitemap, robots
* - public assets (.png, .svg, .ico etc.)
*/
'/((?!api|_next/static|_next/image|favicon\\.ico|sitemap\\.xml|robots\\.txt).*)',
],
}
The negative lookahead (?!...) is standard regex — the matcher config supports it fully.
The auth gap you probably missed: Server Functions
This is the harder bug to find. From the Next.js 16 docs:
"Server Functions are not separate routes in this chain. They are handled as POST requests to the route where they are used, so a Proxy matcher that excludes a path will also skip Server Function calls on that path."
What this means in practice: if your matcher excludes /dashboard/:path* for any reason, it also excludes Server Function calls ("use server" actions) that live inside your dashboard pages. A user without a session could call those Server Functions directly.
The fix is not a matcher change — it is adding authorization inside each Server Function:
```js filename="app/dashboard/actions.js"
'use server'
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
export async function updateProfile(formData) {
const supabase = await createClient()
const { data: claims } = await supabase.auth.getClaims()
if (!claims) {
redirect('/login')
}
// safe to proceed
await supabase.from('profiles').update({ ... }).eq('id', claims.sub)
}
Never rely on proxy alone to protect Server Functions. Proxy is a network-level gate, not an application-level one.
## The `_next/data` gotcha
There is one non-obvious behavior from the docs: even when you exclude `_next/data` in your negative matcher, proxy still runs for `_next/data` routes. This is intentional — it prevents you from accidentally protecting a page route but leaving its data route unprotected.
```js filename="proxy.js"
export const config = {
matcher:
// Note: _next/data exclusion here is intentionally ignored by Next.js
'/((?!api|_next/data|_next/static|_next/image|favicon\\.ico).*)',
}
// Proxy STILL runs for /_next/data/* despite the exclusion above
Do not add _next/data to your exclusion list expecting it to work — it does not. Proxy runs for data routes regardless.
Checklist after migration
- [ ] File renamed from
middleware.tstoproxy.ts(or run the codemod). - [ ] Function renamed from
middleware()toproxy(). - [ ] Matcher excludes
_next/static,_next/image,favicon.ico,sitemap.xml,robots.txt. - [ ] Load the app unauthenticated and verify CSS/JS/images load correctly (not redirected).
- [ ] Load the app authenticated and verify protected pages still redirect to login when session is missing.
- [ ] Authorization added inside each Server Function — not relying on proxy alone.
- [ ] For Supabase: using
getClaims()inside Server Functions, notgetSession().
When this fix is not the right fix
If your proxy logic is legitimately complex and you cannot use a simple negative matcher, prefer running proxy on specific paths with an allowlist (['/dashboard/:path*', '/api/private/:path*']) rather than a global exclude. An allowlist is harder to misconfigure because it fails closed — a new route starts unprotected until you add it, which is visible.
A global exclude fails open in the other direction: a new route is automatically protected, but you might accidentally exclude a path that should be protected.
Related
- Next.js middleware patterns complete guide — covers the full matcher spec, conditional logic, and edge cases.
- Next.js middleware not running on Vercel production — a related issue: proxy runs locally but not in production.
- Supabase auth complete session middleware guide — how to wire getClaims() correctly for Next.js proxy auth.
Originally published at https://www.iloveblogs.blog
Top comments (0)