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.
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; typingrequestasNextRequeststill works)
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
* - .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:
'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.
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, and the image extensions you serve frompublic/. - 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. 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
runtimeconfig option is not available in Proxy files. Setting theruntimeconfig 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.tscarriedexport const runtime = 'nodejs'(or'edge'), delete that line when you rename the file. Keeping it inproxy.tsthrows. If the code genuinely needs the Edge runtime, the Next.js 16 upgrade guide says to keep usingmiddlewarefor now: "Theedgeruntime is NOT supported inproxy." - 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:
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
Still stuck? Paste your error.
Paste the error message or stack trace — we check it against every fix and tool on the site.
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.
Continue Reading
Next.js 'URL is malformed' error: use only absolute URLs
Reproduced on Next.js 16.4.2: the relative redirect that throws, the absolute redirect that then loops on itself, and the basePath case where new URL quietly drops the prefix.
Fix Invariant: attempted to hard navigate to the same URL
Reproduced on Next.js 13.1.0, 13.2.0 and 16.4.3: why a NextResponse.next() middleware broke the 404 page, why the same invariant still fires after a redeploy, and the catch that stops it.
How to Access Route Parameter Inside getServerSideProps
Learn why your dynamic route parameter appears as undefined in getServerSideProps and how to correctly extract it from the context object for server-side data fetching.


