Next.js proxy.js matcher not working: static assets and auth gaps
nextjs-supabase

Next.js proxy.js matcher not working: static assets and auth gaps

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.

2026-06-27
7 min read
Next.js proxy.js matcher not working: static assets and auth gaps

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.tsproxy.ts (or .js)
  • Function: export function middleware()export function proxy()
  • Type: NextProxy (new type, replaces inline function typing)

The codemod handles the rename:

bash
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 the public/ folder."

So this naive 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.

Exclude paths that should never touch 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
'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
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.

Frequently Asked Questions

|

Have more questions? Contact us

Written by

Mahdi Br
Mahdi Br

Full-Stack Dev — Next.js & Supabase

Solo developer building SaaS products with Next.js and Supabase. Writing about production patterns the official docs skip.

Remote

One email a month — no fluff

RLS gotchas, Next.js cache debugging, and the one Supabase setting that bit me last month.