Summary
The nextjs skill teaches that Next.js 16 renamed the middleware matcher export from config to proxyConfig. It did not. Next 16 renamed only the file (middleware.ts → proxy.ts) and the handler function (middleware() → proxy()). The matcher export is still config.
Because Next reads that binding by name during static analysis at build time, an export named proxyConfig is never read: the matcher is silently ignored and the proxy runs on every request, including _next/static, _next/image, favicon.ico, and image assets that the matcher was written to exclude. There is no build error and no warning.
This is a "fails safe, but silently wrong" bug: the proxy over-runs, never under-runs, so nothing becomes under-protected — but any per-request work in the proxy (auth checks, session refresh, remote calls) now executes on every static asset request, and the author believes it does not.
An agent following this skill will write a proxy whose matcher does nothing.
Affected files
Both distributed plugins ship the same content:
vercel 0.44.0 (claude-plugins-official) — skills/nextjs/references/file-conventions.md
vercel-plugin 0.32.3 (vercel-labs) — skills/nextjs/references/file-conventions.md
(and the corresponding skills/nextjs/upstream/references/file-conventions.md in each)
Offending lines, identical in both:
126: export const proxyConfig = {
127: matcher: ['/dashboard/:path*', '/api/:path*'],
128: };
134: | v16+ | `proxy.ts` | `proxy()` | `proxyConfig` |
Evidence
1. Official Next.js documentation. The canonical proxy.ts example in the Next 16 docs (docs/01-app/03-api-reference/03-file-conventions/proxy.mdx, also shipped inside the npm package at next/dist/docs/.../proxy.md) uses:
export const config = {
matcher: ['/about/:path*', '/dashboard/:path*'],
}
The docs' own unit-testing example likewise passes a variable named config.
2. The compiler reads the literal string config. In next@16.2.9, dist/build/analysis/get-page-static-info.js extracts the matcher via:
extractExportedConstValue(ast, 'config')
and feeds the result to getMiddlewareMatchers(...). grep -rn "proxyConfig" node_modules/next/dist/ returns zero matches — the identifier does not exist anywhere in Next's source.
3. Differential build. Same proxy file, same matcher array, only the export identifier changed; built with next build (Turbopack) on next@16.2.9.
Reproducer — proxy.ts at project root:
import { NextResponse, type NextRequest } from 'next/server'
export function proxy(_request: NextRequest) {
return NextResponse.next()
}
// Variant A (as the skill teaches):
export const proxyConfig = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
}
// Variant B (correct):
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
}
Inspecting the compiled proxy registration in .next/server/chunks/:
- Variant A (
proxyConfig) → e.s(["proxy",0 — no matcher object is attached to the registration, and no chunk associates a matcher with "proxy" at all. The matcher array survives in the bundle only as a dead constant.
- Variant B (
config) → matcher:["/((?!_next/static|_next/image|favicon.ico).*)"]},"proxy",0 — the matcher is attached.
4. Runtime confirmation via Next's own matcher oracle. Using unstable_doesMiddlewareMatch from next/experimental/testing/server with the exported config:
| URL |
matches |
/_next/static/chunks/main.js |
false |
/favicon.ico |
false |
/dashboard |
true |
With the export named proxyConfig, there is no config binding to hand the oracle at all.
A note for anyone verifying this
.next/server/middleware-manifest.json is not a reliable oracle under a Turbopack build. It stays an empty stub ({"middleware": {}, "sortedMiddleware": [], "functions": {}}) even when the proxy is compiled and its matcher is correctly applied. Checking that file will make a correct fix look broken. Use the compiled chunk registration or unstable_doesMiddlewareMatch instead.
Suggested fix
In skills/nextjs/references/file-conventions.md (both copies, in both plugins):
-
Line 126: export const proxyConfig = { → export const config = {
-
Line 134: | v16+ | \proxy.ts` | `proxy()` | `config` (unchanged) |`
-
Add a note under the version table, since the asymmetry is the whole trap:
Only the file and the function were renamed in v16. The matcher export is still config — Next reads that binding by name at build time, so an export named proxyConfig is never read and the matcher is silently ignored.
Summary
The
nextjsskill teaches that Next.js 16 renamed the middleware matcher export fromconfigtoproxyConfig. It did not. Next 16 renamed only the file (middleware.ts→proxy.ts) and the handler function (middleware()→proxy()). The matcher export is stillconfig.Because Next reads that binding by name during static analysis at build time, an export named
proxyConfigis never read: the matcher is silently ignored and the proxy runs on every request, including_next/static,_next/image,favicon.ico, and image assets that the matcher was written to exclude. There is no build error and no warning.This is a "fails safe, but silently wrong" bug: the proxy over-runs, never under-runs, so nothing becomes under-protected — but any per-request work in the proxy (auth checks, session refresh, remote calls) now executes on every static asset request, and the author believes it does not.
An agent following this skill will write a proxy whose matcher does nothing.
Affected files
Both distributed plugins ship the same content:
vercel0.44.0 (claude-plugins-official) —skills/nextjs/references/file-conventions.mdvercel-plugin0.32.3 (vercel-labs) —skills/nextjs/references/file-conventions.md(and the corresponding
skills/nextjs/upstream/references/file-conventions.mdin each)Offending lines, identical in both:
Evidence
1. Official Next.js documentation. The canonical
proxy.tsexample in the Next 16 docs (docs/01-app/03-api-reference/03-file-conventions/proxy.mdx, also shipped inside the npm package atnext/dist/docs/.../proxy.md) uses:The docs' own unit-testing example likewise passes a variable named
config.2. The compiler reads the literal string
config. Innext@16.2.9,dist/build/analysis/get-page-static-info.jsextracts the matcher via:and feeds the result to
getMiddlewareMatchers(...).grep -rn "proxyConfig" node_modules/next/dist/returns zero matches — the identifier does not exist anywhere in Next's source.3. Differential build. Same proxy file, same matcher array, only the export identifier changed; built with
next build(Turbopack) onnext@16.2.9.Reproducer —
proxy.tsat project root:Inspecting the compiled proxy registration in
.next/server/chunks/:proxyConfig) →e.s(["proxy",0— no matcher object is attached to the registration, and no chunk associates a matcher with"proxy"at all. The matcher array survives in the bundle only as a dead constant.config) →matcher:["/((?!_next/static|_next/image|favicon.ico).*)"]},"proxy",0— the matcher is attached.4. Runtime confirmation via Next's own matcher oracle. Using
unstable_doesMiddlewareMatchfromnext/experimental/testing/serverwith the exportedconfig:/_next/static/chunks/main.jsfalse/favicon.icofalse/dashboardtrueWith the export named
proxyConfig, there is noconfigbinding to hand the oracle at all.A note for anyone verifying this
.next/server/middleware-manifest.jsonis not a reliable oracle under a Turbopack build. It stays an empty stub ({"middleware": {}, "sortedMiddleware": [], "functions": {}}) even when the proxy is compiled and its matcher is correctly applied. Checking that file will make a correct fix look broken. Use the compiled chunk registration orunstable_doesMiddlewareMatchinstead.Suggested fix
In
skills/nextjs/references/file-conventions.md(both copies, in both plugins):Line 126:
export const proxyConfig = {→export const config = {Line 134:
| v16+ | \proxy.ts` | `proxy()` | `config` (unchanged) |`Add a note under the version table, since the asymmetry is the whole trap: