Fix: Next.js proxy.js matcher not working for static assets
Without a matcher, proxy.js runs on every request including _next/static and public assets. And Server Functions are not separate routes — a matcher gap silently drops auth coverage.
Photo by Towfiqu barbhuiya on Unsplash
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:
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:
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:
'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.
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 aboveDo 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.
- Missing Suspense Boundary with useSearchParams (Next.js)
- Supabase getClaims() vs getSession() in server code: the silent auth bug
Frequently Asked Questions
One email a month — no fluff
RLS gotchas, Next.js cache debugging, and the one Supabase setting that bit me last month.
Continue Reading
Fix Next.js revalidatePath Not Working in Server Actions
Your Server Action mutates data but the page shows stale values until you hard-refresh. `revalidatePath` is one of those APIs that "succeeds" while doing nothing. Here are the six reasons it no-ops, with the exact fix for each — including the one nobody tells you about: `dynamic = 'force-static'`.
Firebase Auth IDBDatabase Transaction Error: 2026 Fix
Firebase Auth stores its session in an IndexedDB database called firebaseLocalStorageDb. When that connection closes mid-session, sign-in throws InvalidStateError. Here is the cause, the SDK version that changed the behaviour, and a Next.js App Router setup that degrades gracefully.
Fix next/image Hostname Not Configured in Next.js
next/image strictly allow-lists remote hosts. The fix is images.remotePatterns in next.config — matched exactly on protocol, hostname, port, and pathname. Get one wrong (http vs https, a subdomain, a missing port) and it still blocks.
Browse by Topic
Find stories that matter to you.
