Next.js

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.

Mahdi Ben RhoumaMahdi Ben Rhouma
Updated
7 min read

A common way this shows up after moving from middleware.ts to proxy.ts in Next.js 16: the page renders unstyled, and static assets — CSS bundles, JS chunks, optimised images — stop loading. In the Network tab each of them answers with a redirect to the login page (NextResponse.redirect uses status 307 unless you pass another one).

The cause: the auth logic was copied but the matcher was not updated. 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 (an optional shorthand type; typing request as NextRequest still works)

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
     * - .png and .svg files served from public/ (add other extensions the same way)
     */
    '/((?!api|_next/static|_next/image|favicon\\.ico|sitemap\\.xml|robots\\.txt|.*\\.png$|.*\\.svg$).*)',
  ],
}

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 } = await supabase.auth.getClaims()
  const claims = data?.claims // data is { claims, header, signature }
 
  if (!claims) {
    redirect('/login')
  }
 
  // safe to proceed
  await supabase.from('profiles').update({ full_name: formData.get('full_name') }).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, and the image extensions you serve from public/.
  • 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. Know which way each one fails. An allowlist fails open: a new route is unprotected until you add it to the list. A global exclude fails closed: a new route is protected automatically, and the risk moves to the exclusion pattern, which can skip a path that should have been protected if it is written too broadly.

proxy.js runs on Node.js in Next.js 16 — and rejects a runtime export#

A frequent assumption carried over from middleware.js is that proxy.js runs on the Edge Runtime. It does not. The Proxy reference states:

"Proxy defaults to using the Node.js runtime. The runtime config option is not available in Proxy files. Setting the runtime config option in Proxy will throw an error."

The version history on the same page traces the change: Middleware gained the Node.js runtime experimentally in v15.2.0, stably in v15.5.0, and v16.0.0 renamed it to Proxy with Node.js as the default. Two consequences for a migration:

  • If your old middleware.ts carried export const runtime = 'nodejs' (or 'edge'), delete that line when you rename the file. Keeping it in proxy.ts throws. If the code genuinely needs the Edge runtime, the Next.js 16 upgrade guide says to keep using middleware for now: "The edge runtime is NOT supported in proxy."
  • A check that needs a Node.js API inside proxy() no longer needs an opt-in. Keep the work small all the same: proxy runs before every request the matcher lets through, including the static-asset requests this article is about if the matcher is wrong.

Scoping the matcher with has and missing instead of only path exclusion#

The negative-lookahead matcher in this article's fix (/((?!api|_next/static|...).*)) excludes by path — it can't see cookies, headers, or query parameters. Once an application needs proxy to run only when a specific condition is true (say, only when a particular cookie is absent, or only for requests carrying a specific header), the matcher config also accepts structured entries with source plus has/missing conditions, which is the more precise tool than growing the negative lookahead further:

js
export const config = {
  matcher: [
    {
      source: '/dashboard/:path*',
      missing: [{ type: 'cookie', key: 'session' }],
    },
  ],
}

This form runs proxy() on /dashboard/* only when the session cookie is genuinely missing, rather than on every request under that path regardless of auth state — useful when the redirect logic itself is cheap to skip entirely rather than run and immediately return NextResponse.next(). has and missing accept cookie, header, and query types, each checked against a key and an optional value. This complements, rather than replaces, the negative matcher pattern above: exclude static-asset paths with the lookahead first, then add has/missing conditions on top for the routes that remain, which keeps the matcher declarative instead of stacking more auth branches inside the proxy() function body itself. For the broader set of matcher patterns this generalizes to — rate limiting, A/B routing, locale detection — see Next.js 15 Middleware: Auth, Rate Limiting & Edge Guide.

Further reading: Cross-Origin-Opener-Policy Blocks window.closed: 2026 Fix, How to Access Route Parameter Inside getServerSideProps, Next.js Query String Params: searchParams + useRouter. Also see Fix Invariant: attempted to hard navigate to the same URL. Also see Next.js 'URL is malformed' error: use only absolute URLs, Next.js favicon not working: app/favicon.ico vs public/.

Frequently Asked Questions

|

Have more questions? Contact us

Still stuck? Paste your error.

Paste the error message or stack trace — we check it against every fix and tool on the site.

Matching happens in your browser. When you search, a cleaned error signature is sent so we can spot errors we do not cover yet — never the raw text, your IP address or your e-mail. Do not paste passwords or keys. Details

Written by

Mahdi Ben Rhouma
Mahdi Ben Rhouma

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 — the latest explained errors

What the site published that month — mostly Next.js, Supabase and Node errors, explained. You confirm by email; unsubscribe any time in one click.