The authentication redirect works, but the page loads without CSS. Image requests appear in the auth logs. Link prefetching calls the session service before anyone clicks. A health check receives a login redirect. These failures look unrelated until the request log shows the same function touching all of them.
In Next.js 16 that function is Proxy, the renamed Middleware convention. Without a matcher, it can run for every request, including framework assets and files from public. The fix is not another pathname condition buried inside the function. The first fix is to define the smallest request surface that should invoke Proxy at all.
Next.js 16 deprecates middleware.ts in favor of proxy.ts. Search results and older examples still say middleware matcher, but new Next.js 16 code should use the Proxy filename and export.
A matcher decides whether Proxy starts
The matcher lives in the exported config object. Next.js evaluates it before running your Proxy function. If the request does not match, the function is never invoked. That makes the matcher both a behavior boundary and a performance boundary.
import {NextResponse} from 'next/server'
import type {NextRequest} from 'next/server'
export function proxy(request: NextRequest) {
return NextResponse.next()
}
export const config = {
matcher: ['/dashboard/:path*', '/account/:path*'],
}Use an include-only matcher when the requirement can be stated positively. Authentication for two private areas is easier to review as a two-entry allowlist than as a negative regular expression that attempts to describe the rest of the application.
/dashboard/:path matches /dashboard/a but not /dashboard/a/b
/dashboard/:path* matches /dashboard, /dashboard/a, and deeper paths
/dashboard/:path+ matches /dashboard/a and deeper paths, but not /dashboard
/dashboard/:path? matches /dashboard and one optional segmentMatcher paths must begin with a slash and are anchored at the start. A matcher for /account will match /account and descendants, but it will not match /team/account.
Exclude framework and metadata requests
Some features genuinely apply to most pages: locale redirects, tenant selection, experiments, or a broad optimistic session check. In that case, use a negative matcher to exclude traffic that cannot benefit from the work.
export const config = {
matcher: [
'/((?!api|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)',
],
}The negative lookahead rejects API routes, generated static chunks, image optimization requests, and three common metadata files. Page requests continue into Proxy. Add other metadata routes your application actually serves, such as manifest.webmanifest or opengraph-image, when the Proxy behavior should not apply to them.
A file at public/logo.svg is requested as /logo.svg, not /public/logo.svg. Excluding public as a prefix does not exclude its files. List critical assets explicitly or use a reviewed file-extension rule.
Do not copy an extension rule without its tradeoff
A common pattern excludes every pathname containing a dot because most public assets have extensions. It is concise, but it also skips legitimate application routes that contain dots, such as usernames, version identifiers, or document slugs. Test the actual route inventory before adopting it.
export const config = {
matcher: ['/((?!api|_next/static|_next/image|.*\..*).*)'],
}Skip prefetch requests when the work is optional
Next.js can prefetch visible links before a visitor clicks. If Proxy performs logging, experimentation, or an avoidable session lookup for every prefetch, one rendered navigation can multiply the work. Matcher objects can require that known prefetch headers are missing.
export const config = {
matcher: [
{
source:
'/((?!api|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)',
missing: [
{type: 'header', key: 'next-router-prefetch'},
{type: 'header', key: 'purpose', value: 'prefetch'},
],
},
],
}Do not skip prefetches automatically when Proxy changes routing or protects static content. An HTML request and its prefetched navigation need consistent behavior. Use the header rule only when bypassing the work cannot produce a different destination, cache policy, or security decision.
Keep the matcher static and the function readable
Next.js statically analyzes matcher values during the build. They must be constants written directly in the config. A computed array, environment-dependent string, or imported runtime value can be ignored because the build cannot extract it reliably.
export const config = {
matcher: ['/dashboard/:path*', '/account/:path*'],
}const protectedRoutes = process.env.PROTECTED_ROUTES?.split(',') ?? []
export const config = {
matcher: protectedRoutes,
}The matcher should make a coarse decision: should this class of request invoke Proxy? The function can then make the fine-grained decision using pathname, method, cookies, headers, or deployment configuration.
import {NextResponse} from 'next/server'
import type {NextRequest} from 'next/server'
export function proxy(request: NextRequest) {
const {pathname} = request.nextUrl
const session = request.cookies.get('session')?.value
if (pathname.startsWith('/dashboard') && !session) {
return NextResponse.redirect(new URL('/login', request.url))
}
return NextResponse.next()
}
export const config = {
matcher: ['/dashboard/:path*'],
}Do not make Proxy the authorization layer
A matcher can reduce unnecessary work and Proxy can perform an optimistic cookie check, but neither proves that a user may read or change data. Next.js recommends placing secure authorization close to the data source and repeating it inside Server Functions and Route Handlers.
Treat Proxy as an early routing check. A crafted request can call a Server Function or Route Handler independently of the UI path you expected. Verify the session and permission again at the protected operation.
'use server'
import {verifySession} from '@/lib/dal'
export async function updateProfile(formData: FormData) {
const session = await verifySession()
if (!session) throw new Error('Unauthorized')
// Validate input and perform the authorized update.
}Server Functions are POST requests to the route where they are used, not separate matcher routes. Moving a function to another page can therefore change whether Proxy sees its request. The authorization inside the function must remain correct even when the matcher changes.
Test the matcher as a route matrix
A regular expression that looks correct is not evidence. Starting with Next.js 15.1, the experimental server testing utilities can evaluate the exported config against real URLs, headers, and cookies. Turn the intended boundary into a table of positive and negative cases.
import {describe, expect, it} from 'vitest'
import {unstable_doesProxyMatch} from 'next/experimental/testing/server'
import {config} from './proxy'
const nextConfig = {}
describe('proxy matcher', () => {
it.each([
['/dashboard', true],
['/dashboard/settings', true],
['/account', true],
['/pricing', false],
['/_next/static/chunk.js', false],
['/_next/image?url=%2Fhero.png&w=1200&q=75', false],
['/favicon.ico', false],
])('%s -> %s', (url, expected) => {
expect(
unstable_doesProxyMatch({config, nextConfig, url}),
).toBe(expected)
})
})Add at least one test for every protected route family, public route family, framework path, metadata file, and important public asset. If the matcher uses has or missing, include header-present and header-absent cases. A route added later should not silently fall into the wrong side of the boundary.
Verify the whole Proxy response when routing changes
Matcher tests prove whether the function runs. They do not prove that the function rewrites or redirects to the correct destination. Use getRedirectUrl, getRewrittenUrl, or isRewrite from the same testing package for behavior tests.
import {NextRequest} from 'next/server'
import {
getRedirectUrl,
} from 'next/experimental/testing/server'
import {proxy} from './proxy'
it('redirects an anonymous dashboard request', async () => {
const request = new NextRequest('https://example.com/dashboard')
const response = await proxy(request)
expect(getRedirectUrl(response)).toBe('https://example.com/login')
})Migrate middleware.ts to proxy.ts deliberately
The Next.js codemod renames the file and the named export. It also updates configuration flags whose names changed. Run it on a branch, then review the matcher and runtime assumptions instead of treating the result as a mechanical filename change.
npx @next/codemod@canary middleware-to-proxy .middleware.ts -> proxy.ts
export function middleware() -> export function proxy()Proxy in Next.js 16 uses the Node.js runtime, and the runtime option is not configurable inside proxy.ts. Verify that authentication libraries and other dependencies work in that runtime. Also review renamed advanced settings such as skipMiddlewareUrlNormalize becoming skipProxyUrlNormalize.
Teams will continue searching for “Next.js middleware matcher” because that is the historical name. Document that Middleware maps to Proxy in Next.js 16 so old incidents and new code remain connected.
Production verification checklist
[ ] Protected page requests invoke Proxy
[ ] Public pages have the intended behavior
[ ] _next/static and _next/image bypass unnecessary work
[ ] Metadata and critical public assets load without redirects
[ ] Prefetch requests match the chosen routing policy
[ ] Server Functions authorize inside the function
[ ] Route Handlers authorize inside the handler
[ ] Matcher tests cover positive and negative paths
[ ] Redirect and rewrite destinations have behavior tests
[ ] Production logs no longer fill with asset requestsIn a preview deployment, load a protected route directly, navigate to it through a Link, refresh it, and repeat the test without a valid session. Inspect the network panel for CSS, JavaScript, images, metadata, and RSC requests. The page and the prefetched navigation must agree about the destination.
The rule that keeps matchers maintainable
Start with the smallest positive route list. Use a negative matcher only when the feature genuinely applies to most pages. Keep the pattern literal, test it against a route matrix, and place real authorization at the data boundary.
A good matcher is deliberately boring. It invokes Proxy for the requests that need it, ignores traffic that cannot benefit, and makes its security limits obvious. Once that boundary is explicit, missing assets, duplicate session checks, and surprising redirects stop looking like unrelated production bugs.
