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.ts to proxy.ts (or run the codemod).
- [ ] Function renamed from
middleware() to proxy().
- [ ] 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, not getSession().
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
Originally published at https://www.iloveblogs.blog