middleware-patterns — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited middleware-patterns (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
Next.js 16 rename. The root request-preprocessing file is now `proxy.ts` (exported function `proxy`). Themiddleware.ts/middlewareconvention is deprecated — it still works (a build warning, no error) and is retained specifically for Edge Runtime use cases, but Vercel's recommended path isproxy.ts. This skill uses "proxy/middleware" for the shared concept and names the file explicitly when the distinction matters. If you are on Next.js 15 or earlier, everything here applies under themiddleware.tsname. See § The File Contract and § Migrating middleware.ts → proxy.ts.
The Next.js request-preprocessing layer is a single async function exported from one root file that runs before route resolution for every request matching its matcher config — proxy.ts (function proxy, Node.js runtime) since Next.js 16, or the deprecated-but-retained middleware.ts (function middleware, Edge Runtime by default) on older versions and Edge-only use cases. It receives a NextRequest and returns a NextResponse in one of four shapes: pass through (next, optionally with modified headers/cookies), rewrite (serve different content under the same URL), redirect (send a 30x and change the URL bar), or a direct response (short-circuit with a body and status). Because it is the only place a request can be intercepted before the framework knows which route it will hit, it is the right home for genuinely cross-cutting concerns — auth UX gates, locale routing, A/B rewrites, per-request CSP nonces, request-ID correlation, bot blocking, geo-routing — applied across many routes through one matcher instead of duplicated per route. Three facts make or break correct use: the runtime is load-bearing (proxy on Node.js has full APIs but the per-matched-request performance budget still forbids per-request I/O; middleware.ts on Edge has a hard capability ceiling and a bundle limit), the matcher is not a security perimeter (and /_next/data runs even when excluded while a matcher exclusion silently skips Server Functions on that path), and — most importantly — this layer is not an authorization boundary (CVE-2025-29927 let attackers bypass it entirely, and a cluster of May-2026 transport-variant bypasses reinforced that authz must live in the route, the Server Action, and the Data Access Layer, with the proxy auth check being only a fast UX redirect). The Next.js 16 rename from middleware to proxy itself encodes the discipline: keep this layer small, fast, cross-cutting, and never the sole gate — and reach for native next.config redirects/headers, the platform WAF, and route-level auth first.
The discipline of designing the Next.js request-preprocessing layer: the one-file-per-project contract (proxy.ts since Next.js 16, or the deprecated middleware.ts, at the root or under src/, single function export), the runtime split (proxy runs on Node.js and its runtime config throws; middleware.ts runs on Edge with the Edge capability ceiling) and what each runtime can and cannot do, the matcher config that filters which paths trigger the layer (including the _next/data-always-runs and Server-Function-coverage gotchas), the NextRequest / NextResponse API surface (cookies, headers, and geolocation()/ipAddress() from @vercel/functions since request.geo/request.ip were removed in Next.js 15), the four response shapes (next, rewrite, redirect, direct response), waitUntil for background work, the official next/experimental/testing/server test utilities, the canonical pattern library (authentication UX gate, locale routing, A/B testing, security header injection, geo-routing, bot blocking, request-id correlation), the performance discipline that every matched request pays the cost, the CVE-2025-29927 lesson that this layer is not a security boundary, and the central design rule: it is for cross-cutting concerns that apply across many routes — never for per-route business logic.
The Pages Router's request lifecycle was: server hits getServerSideProps, which returns props, which render the page. The App Router added more layers (Server Components, Server Actions, Route Handlers), but kept one thing constant — they all run after the route is resolved.
The proxy/middleware layer runs before. It is the only layer where you can intercept a request without knowing which route it will eventually hit. That makes it the right home for concerns that apply across the entire app or large subsets of it: "every request needs a request-id header", "every request to /admin/* needs a role check", "every page needs a CSP nonce".
Next.js 16's rename from middleware to proxy encodes a deliberate stance. The team renamed it because "middleware" invited Express-style "do all my server logic here" misuse, and because the feature behaves as a network proxy in front of the app. Vercel now frames it as a feature to "use as a last resort" — reach first for native next.config redirects/headers, route-level auth, and the Data Access Layer. The architectural trade is breadth for power. The layer:
proxy on Node.js (full Node APIs, but cross-cutting perf still forbids per-request I/O), middleware.ts on Edge (limited APIs, no Node-specific dependencies, no large packages).In exchange, it can shape the entire request/response edge in a single place. Done well, it removes ceremony from every route; done badly, it adds latency to every request, concentrates business logic in a hard-to-test file, and gets mistaken for an access-control gate it cannot be.
The discipline is to keep it small, fast, cross-cutting, and never the sole authorization gate. When a piece of logic only applies to one route, it does not belong here. When it requires a per-request database lookup that adds 50ms, it does not belong here. When it is the only thing standing between an attacker and /admin, it is not enough.
Vercel's own guidance frames this layer as the feature to reach for last. Before writing per-request code that runs on every matched request, check whether a simpler, cheaper, statically-evaluated upstream feature already does the job:
| Need | Reach for this first | Use proxy only when |
|---|---|---|
Permanent or static redirect (/old → /new) | next.config redirects | the decision is dynamic (depends on a cookie, header, geo, or A/B bucket) |
| Static security headers on every response | next.config headers with a static CSP | the header must vary per request (a per-request CSP nonce) |
| Blocking bad traffic, rate limiting, bot management | the platform WAF / firewall / bot-management layer | you need app-specific logic the WAF can't express |
| Strict CSP for static pages | a hash/SRI-based policy where the project version supports it | a per-request nonce is genuinely required and the page is intentionally dynamic |
| Authorization | route-level checks + the Data Access Layer | never — proxy is a UX redirect, not the authz boundary |
next.config redirects and headers run before proxy in the routing chain and cost no per-request execution. The full execution order is: next.config headers → next.config redirects → proxy → beforeFiles rewrites → filesystem routes (public/, _next/static/, pages/, app/) → afterFiles rewrites → dynamic routes → fallback rewrites. Every concern you can push to a static upstream feature is latency you do not pay on every request — and a smaller, faster proxy is a better proxy. Proxy is the right tool when the app must decide at the HTTP edge using request facts before the route is resolved; it is not a general-purpose "before hook" and should not become a hidden second router.
// proxy.ts (Next.js 16+, project root or src/) — Node.js runtime
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function proxy(request: NextRequest) { // mark async if you await inside
// ... transform or gate the request ...
return NextResponse.next()
}
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
}// middleware.ts (Next.js ≤15, or Next.js 16 when you NEED the Edge Runtime) — DEPRECATED in 16
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export async function middleware(request: NextRequest) {
return NextResponse.next()
}
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
}proxy/middleware function, with helpers imported from small modules.proxy (or middleware) export both work; pick one. Multiple proxies from the same file are not supported.proxy.ts and middleware.ts coexist, behavior is unstable — delete middleware.ts once proxy.ts exists.npx @next/codemod@canary middleware-to-proxy . renames the file and the exported function (see § Migrating middleware.ts → proxy.ts).request (NextRequest) and event (NextFetchEvent) parameters: export const proxy: NextProxy = (request, event) => { … }.proxy.ts (Next.js 16) | middleware.ts (deprecated; Edge) | |
|---|---|---|
| Runtime | Node.js only — runtime config throws if set | Edge (Node.js opt-in via experimental.nodeMiddleware + runtime: 'nodejs', stable since 15.5) |
Node crypto, fs, Buffer, full SDKs | ✅ available (but see perf discipline) | ❌ Edge: not available |
| Bundle ceiling | Node server limits | Vercel, after gzip: 1 MB Hobby / 2 MB Pro / 4 MB Enterprise; one Node-only import breaks the build |
Web Crypto, fetch, Web Streams | ✅ | ✅ |
| Status | Recommended (Next.js 16) | Works with a warning; for Edge-only needs |
The runtime is what decides whether the old "no Node APIs, no DB, hand-roll everything" advice applies. On Edge (middleware.ts) it is a hard capability wall. On Node (proxy.ts) those APIs exist — but the performance discipline still forbids per-request database calls, because the cost multiplies across every matched request. The constraint moved from "you can't" to "you mustn't." Do not turn the Node.js runtime into permission to do expensive work: a database query, remote JWKS fetch, or SDK initialization in proxy still adds cost to every matched request and can become a global latency tax.
export const config = {
matcher: [
'/dashboard/:path*', // glob
'/((?!api|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)', // negative lookahead
{
source: '/api/admin/:path*',
has: [{ type: 'header', key: 'Authorization' }],
missing: [{ type: 'cookie', key: 'session' }], // skip when cookie absent
},
{
source: '/((?!api|_next/static|_next/image|favicon.ico).*)',
missing: [ // skip speculative prefetches
{ type: 'header', key: 'next-router-prefetch' },
{ type: 'header', key: 'purpose', value: 'prefetch' },
],
},
],
}Matchers compile to regular expressions at build time. They cannot use runtime values — every source must be a build-time constant, must start with /, may use named parameters (:path*), and may add has / missing conditions to match on headers, cookies, or query params. Complex negative lookaheads are common because the default-matches-everything behavior is rarely what you want — image fetches, static assets, prefetch requests, and webhook routes should usually be excluded. The baseline exclusion list usually covers api routes (unless this layer is intentionally protecting a whole API subtree), /_next/static and /_next/image, static metadata files such as favicon.ico / sitemap.xml / robots.txt, webhook paths that need exact raw-body/signature handling in their Route Handler, and prefetch requests when the concern need not run for speculative navigation.
Three gotchas the docs call out explicitly:
/_next/static and /_next/image, which makes every image fetch run the layer on the hot path.import { NextResponse } from 'next/server'
// 1. Pass through — let the request continue to its route
return NextResponse.next()
// 2. Rewrite — internally route to a different path; URL bar unchanged
return NextResponse.rewrite(new URL('/en/about', request.url))
// 3. Redirect — send a 30x to the browser; URL bar changes
return NextResponse.redirect(new URL('/login', request.url))
// 4. Direct response — short-circuit; return a response without hitting any route
return new NextResponse('Forbidden', { status: 403 })
// (Response.json(...) and Response.redirect(...) also work for direct responses)Choose based on what the user should see and what should change:
| Goal | Use |
|---|---|
| Continue to the originally requested route, possibly with modified headers/cookies | next() (often with .headers.set() on the response) |
| Serve a different route's content under the same URL (A/B test, locale variant, feature flag) | rewrite |
| Send the user to a different URL (login redirect, canonical redirect, locale-detection redirect) | redirect |
| Block the request entirely (rate limit hit, bot blocked, missing auth on protected API) | direct response with appropriate status |
Rewrite vs redirect is a load-bearing distinction: rewrites are invisible to the user (the URL stays the same), redirects are visible (the URL changes and the browser does a second request). If you want the user to see they've been moved (/old-path → /new-path), redirect. If you want to keep their URL and serve different content (A/B variant, internal locale path), rewrite.
Always rewrite with `NextResponse.rewrite()`, not a hand-rolled `fetch()`. For App Router rewrites, Next.js propagates internal RSC/Flight headers (the request carries an RSC marker; the response needs the matching vary handling) so a rewritten route still serves the correct React Server Component payload to a client-side navigation. A custom fetch() that re-implements the rewrite can drop or mishandle those headers — the page works on a hard load but breaks (or serves the wrong/stale RSC payload) on soft client-side navigation. Let NextResponse.rewrite() own the transport; only set the headers you own on top of it.
Before reaching for a redirect/rewrite here, check `next.config`. Staticredirectsandheadersinnext.config.jsrun before proxy in the routing chain and need no per-request code. Use proxy only when the decision is dynamic (depends on a cookie, header, geo, or A/B bucket). The full execution order is:next.configheaders→next.configredirects→ proxy →beforeFilesrewrites → filesystem routes →afterFilesrewrites → dynamic routes →fallbackrewrites.
NextResponse.next({ request: { headers } }) changes the request seen by the route/rendering layer; response.headers.set(...) changes the response seen by the browser. These are two different surfaces — set both only when both consumers need the value (e.g. a CSP nonce the renderer reads and the browser enforces). The rules:
new Headers(request.headers) and set the specific keys you need. Do not blanket-forward all inbound headers to an upstream fetch or rewrite target — user-controlled headers can leak credentials, override framework internals (e.g. x-middleware-subrequest, see § The Auth Reality), or exceed header-size limits (431 Request Header Fields Too Large).request.cookies changes only the request object the route sees, not the cookie stored by the browser — set browser-visible cookies with response.cookies.set(...).request.cookies.get(...); write with response.cookies.set(...).fetch()-based rewrite that must re-implement them (see the response-shapes note above).middleware.ts only)These constraints apply to `middleware.ts` on the Edge Runtime. On proxy.ts (Node.js) the Node APIs below are available — but the performance discipline in § Performance Discipline still applies.| Capability | Available on Edge |
|---|---|
fetch | ✅ |
Web Crypto (crypto.subtle, crypto.randomUUID) | ✅ |
Web Streams (ReadableStream, TransformStream) | ✅ |
URL, URLSearchParams, Request, Response | ✅ |
setTimeout / setInterval | ⚠️ best-effort, may not fire after response |
Node crypto, fs, child_process, net, dns, Buffer | ❌ |
require / dynamic code evaluation | ❌ |
| Most npm packages that aren't pure JS | ❌ |
| Large bundle sizes | ❌ (Vercel, after gzip: 1 MB Hobby / 2 MB Pro / 4 MB Enterprise) |
Edge code is bundled and shipped to Edge nodes globally. Cold-start is fast (~10–50ms) but the trade is a tight capability surface. Anything you import — including transitive dependencies — must be Edge-compatible. A single import of a Node-only package breaks the build.
Practical consequence on Edge: don't reach for ORMs, full SDKs, or complex libraries. Hand-roll the small piece you need (decode a JWT, hash a token, parse a cookie). If the work genuinely needs Node — verifying a webhook signature with a vendor SDK, hitting a database — either move to proxy.ts on Node.js (Next.js 16) or push the work down into a Route Handler / Server Action.
waitUntilThe NextFetchEvent second argument exposes waitUntil(promise), which extends the request's lifetime until the promise settles — for fire-and-forget work that must not block the response (analytics beacons, audit logging). Use it only for non-critical side effects; if the side effect must complete for correctness, it belongs in the route/action/job that owns the operation, not in proxy.
import { NextResponse } from 'next/server'
import type { NextFetchEvent, NextRequest } from 'next/server'
export function proxy(request: NextRequest, event: NextFetchEvent) {
event.waitUntil(
fetch('https://analytics.example.com', {
method: 'POST',
body: JSON.stringify({ pathname: request.nextUrl.pathname }),
}).catch(() => undefined),
)
return NextResponse.next()
}export function proxy(request: NextRequest) {
const session = request.cookies.get('session')?.value
const { pathname } = request.nextUrl
const isProtected = pathname.startsWith('/dashboard') || pathname.startsWith('/admin')
if (isProtected && !session) {
const url = new URL('/login', request.url)
url.searchParams.set('redirectTo', pathname)
return NextResponse.redirect(url)
}
return NextResponse.next()
}
export const config = {
matcher: ['/dashboard/:path*', '/admin/:path*'],
}This redirect is a UX optimization — it sends a logged-out user to /login quickly so they don't load a page that would fail anyway. It is not the authorization decision. The session cookie is checked for presence, not cryptographically verified, and even a perfect check here can be bypassed entirely (CVE-2025-29927) or silently skipped by a matcher change. Every protected route, Route Handler, and Server Action must verify authorization itself, ideally through a shared Data Access Layer. A per-request database lookup to validate the session does not belong here — that 50ms hits every protected page load; defer it to the route.
// In the page, route, action, or data-access layer: the REAL authorization
async function requireAdmin() {
const session = await verifySession() // cryptographic check, DB/role lookup
if (!session?.roles.includes('admin')) {
throw new Error('Forbidden')
}
return session
}const LOCALES = ['en', 'es', 'fr', 'de']
const DEFAULT_LOCALE = 'en'
export function proxy(request: NextRequest) {
const { pathname } = request.nextUrl
const hasLocale = LOCALES.some((l) => pathname.startsWith(`/${l}/`) || pathname === `/${l}`)
if (hasLocale) return NextResponse.next()
const accept = request.headers.get('accept-language') ?? ''
const detected = LOCALES.find((l) => accept.includes(l)) ?? DEFAULT_LOCALE
return NextResponse.redirect(new URL(`/${detected}${pathname}`, request.url))
}A first-visit user lands on /about, gets redirected to /en/about. Subsequent visits to locale-prefixed paths pass through. The redirect-once pattern keeps the URL canonical and lets the rest of the app assume locale is in the path. (If you use next-intl or a similar library, note that its middleware export must also be renamed to proxy under Next.js 16 — check the library's v16 migration notes.)
export function proxy(request: NextRequest) {
if (request.nextUrl.pathname !== '/pricing') return NextResponse.next()
let variant = request.cookies.get('pricing-variant')?.value
if (!variant) {
variant = crypto.randomUUID().charCodeAt(0) % 2 === 0 ? 'a' : 'b' // Edge-safe random pick
}
const url = new URL(`/pricing-${variant}`, request.url)
const response = NextResponse.rewrite(url)
response.cookies.set('pricing-variant', variant, { maxAge: 60 * 60 * 24 * 30 })
return response
}The user sees /pricing in their URL bar but receives /pricing-a or /pricing-b. The cookie pins their variant so subsequent visits are consistent. The rewrite preserves the canonical URL for analytics and sharing. (Math.random() works too; crypto.randomUUID() is shown because it is available on both Edge and Node.)
export function proxy(request: NextRequest) {
// btoa(crypto.randomUUID()) works on BOTH Edge and Node.
// `Buffer.from(...).toString('base64')` is Node-only — it breaks the Edge build.
const nonce = btoa(crypto.randomUUID())
const csp = [
`default-src 'self'`,
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
`style-src 'self' 'nonce-${nonce}'`,
`img-src 'self' blob: data:`,
`font-src 'self'`,
`object-src 'none'`,
`base-uri 'self'`,
`form-action 'self'`,
`frame-ancestors 'none'`,
`upgrade-insecure-requests`,
].join('; ')
const requestHeaders = new Headers(request.headers)
requestHeaders.set('x-nonce', nonce)
requestHeaders.set('content-security-policy', csp)
const response = NextResponse.next({ request: { headers: requestHeaders } })
response.headers.set('content-security-policy', csp)
response.headers.set('x-content-type-options', 'nosniff')
response.headers.set('referrer-policy', 'strict-origin-when-cross-origin')
response.headers.set('permissions-policy', 'camera=(), microphone=(), geolocation=()')
return response
}The nonce flows to the request headers (so Server Components can read it via headers() and inject it into <script> tags) and to the response headers (so the browser enforces the CSP). The policy itself is just an example; the actual rules belong to the broader security strategy in security-fundamentals. Avoid setting very large headers — they can trigger 431 Request Header Fields Too Large depending on the backend.
The nonce has a rendering cost. A per-request CSP nonce is unique per response, so it forces the affected pages into dynamic rendering — they can no longer be statically generated, served from the CDN cache, used by ISR, or kept as a PPR static shell. If you do not actually need per-request script allow-listing, prefer a static CSP in next.config headers (cacheable, no dynamic-rendering penalty) or a hash/SRI-based policy. Reach for the nonce only when 'strict-dynamic' script allow-listing genuinely requires it.
Never cache or share a nonce across responses, and never derive nonce material from untrusted inbound headers. A nonce is only safe if it is unique per response and generated server-side: caching the page that carries it (or otherwise replaying one nonce to multiple users) lets an injected script execute under a still-valid nonce, and accepting a Content-Security-Policy / nonce value supplied in the request lets an attacker control the policy. Both are the failure behind the May 2026 CSP-nonce shared-cache XSS (CVE-2026-44581 / GHSA-ffhc-5mcf-pf4q, patched 15.5.18 / 16.2.6). It is the same fact as the rendering cost, stated as a security rule: a nonce'd response and a cacheable response are mutually exclusive — stay patched, generate the nonce in proxy (never from request headers), strip inbound CSP/nonce headers from untrusted traffic on unpatched apps, keep nonce'd pages dynamic, and use a static/hash CSP for anything you want cached.
import { geolocation } from '@vercel/functions' // request.geo was removed in Next.js 15
export function proxy(request: NextRequest) {
const { country } = geolocation(request) // 'US', 'GB', … (undefined off Vercel)
if (country === 'GB' && !request.nextUrl.pathname.startsWith('/uk')) {
return NextResponse.redirect(new URL(`/uk${request.nextUrl.pathname}`, request.url))
}
return NextResponse.next()
}`request.geo` and `request.ip` were removed from `NextRequest` in Next.js 15 — the framework kept geolocation as a Vercel-specific feature. On Vercel, read it via geolocation(request) and ipAddress(request) from @vercel/functions (install the package; the Next.js 15 codemod does this for you). On other hosts, parse the platform's own geo headers (e.g. x-vercel-ip-country, or your CDN's equivalent) and read defensively — values may be undefined locally, and may be wrong when another proxy sits in front of the deployment unless trusted-proxy support is configured. For hard enforcement (e.g. blocking a region), prefer the platform firewall/WAF where available; app-level geo routing is usually product behavior, not a security boundary.
const BLOCKED_AGENTS = [/AhrefsBot/i, /SemrushBot/i, /MJ12bot/i]
export function proxy(request: NextRequest) {
const ua = request.headers.get('user-agent') ?? ''
if (BLOCKED_AGENTS.some((re) => re.test(ua))) {
return new NextResponse('Forbidden', { status: 403 })
}
return NextResponse.next()
}UA strings are trivially spoofable — bot blocking via user-agent works for honest crawlers but does not stop adversaries. Use this for noise reduction, not security.
export function proxy(request: NextRequest) {
const requestId = request.headers.get('x-request-id') ?? crypto.randomUUID()
const requestHeaders = new Headers(request.headers)
requestHeaders.set('x-request-id', requestId)
const response = NextResponse.next({ request: { headers: requestHeaders } })
response.headers.set('x-request-id', requestId)
return response
}The request-id flows through to the route (readable via headers()) and back out to the client (visible in DevTools). Pair with structured logging that includes the id — optionally flushed via event.waitUntil(...) — and you get end-to-end traceability.
The single most important security fact about this layer: it is not an authorization boundary, and treating it as one has already caused a critical, widely-exploited vulnerability.
CVE-2025-29927 (CVSS 9.1, disclosed March 2025) let an attacker bypass middleware entirely by sending a crafted internal x-middleware-subrequest header. That header was meant for Next.js's own loop-prevention and was trusted without verifying its origin — so spoofing it made the framework skip the middleware layer, and with it every auth check implemented there. It was patched in 14.2.25 and 15.2.3 (and 12.3.5 / 13.5.9 for older lines); if you cannot upgrade, block external requests carrying x-middleware-subrequest at the edge.
The patch closed that specific hole, but the architectural lesson is permanent and reinforced by the matcher behavior above:
/login), never the authorization decision.CVE-2025-29927 was not a one-off. Next.js's May 2026 security release patched a cluster of advisories that hit this exact layer again, several of which defeat a proxy/middleware auth gate by a route the matcher never saw:
| Advisory | What it does | Patched in |
|---|---|---|
| CVE-2026-44575 / GHSA-267c-6grr-h53f | App Router middleware/proxy bypass via segment-prefetch routes — a crafted .rsc / segment-prefetch URL resolves to the same page without matching the intended matcher rule, reaching protected content without the auth check. | 15.5.18 / 16.2.6 |
| CVE-2026-45109 / GHSA-26hh-7cqf-hhc6 | Incomplete-fix follow-up to the above: the original fix did not cover middleware.ts built with Turbopack. Turbopack users must go one patch higher. | 15.5.18 / 16.2.6 |
| CVE-2026-44574 / GHSA-492v-c6pp-mqqv | Bypass via dynamic route parameter injection — another transport/route-shape variant that sidesteps matcher-based checks. | 15.5.18 / 16.2.6 |
| CVE-2026-44581 / GHSA-ffhc-5mcf-pf4q | XSS via CSP nonce + a shared cache — a per-request nonce cached and replayed across users, or nonce material derived from an untrusted inbound request header, lets injected script execute under a valid nonce. Directly relevant to § pattern 4: a nonce must be generated server-side, never cached/shared, and never read from request headers. | 15.5.18 / 16.2.6 |
Two durable takeaways reinforce everything above:
x-middleware-subrequest) and inbound CSP/nonce headers from untrusted traffic when an advisory says to.There is one proxy file. Combine concerns inside it — typically in a clear order:
export function proxy(request: NextRequest) {
// 1. Block bots first — fast reject
const ua = request.headers.get('user-agent') ?? ''
if (BLOCKED_AGENTS.some((re) => re.test(ua))) {
return new NextResponse('Forbidden', { status: 403 })
}
// 2. Locale detection — redirect once for first-visit users
const localeRedirect = applyLocaleRouting(request)
if (localeRedirect) return localeRedirect
// 3. Auth UX gate — redirect to login for protected routes (NOT the security boundary)
const authRedirect = applyAuthGate(request)
if (authRedirect) return authRedirect
// 4. Pass through with security headers + request-id
return applyHeaders(request)
}Each helper returns either a short-circuit NextResponse or null (continue). The shape is a small chain of guards; the file stays readable. When the chain grows past ~5 concerns, it's a signal that the layer is doing too much — push something down to per-route logic or to a separate request-time hook.
The old "this file is impossible to test" excuse no longer holds. Since Next.js 15.1, next/experimental/testing/server ships utilities to unit-test the layer:
import { unstable_doesProxyMatch, isRewrite, getRewrittenUrl, getRedirectUrl } from 'next/experimental/testing/server'
// Assert the matcher includes/excludes a path
expect(unstable_doesProxyMatch({ config, nextConfig, url: '/dashboard' })).toEqual(true)
expect(unstable_doesProxyMatch({ config, nextConfig, url: '/_next/static/x.js' })).toEqual(false)
expect(
unstable_doesProxyMatch({ config, nextConfig, url: '/dashboard', headers: { purpose: 'prefetch' } }),
).toEqual(false)
// Exercise the function and assert the response shape
const response = await proxy(new NextRequest('https://app.example.com/about'))
expect(isRewrite(response)).toEqual(true)
expect(getRewrittenUrl(response)).toEqual('https://app.example.com/en/about')Test three things:
getRedirectUrl exists for redirect responses).Where the project version lacks the experimental helpers, unit-test the pure helper functions and add integration tests around the real Next.js server.
Every matched request pays the cost. Three rules:
/dashboard/* needs the gate, match only /dashboard/*. Don't run checks against image fetches.waitUntil for any logging/analytics so it doesn't block the response.The performance budget is invisible until you load-test the site and see this layer dominate the latency profile. Build it in from the start.
middleware.ts → proxy.tsNext.js 16 ships a codemod:
npx @next/codemod@canary middleware-to-proxy .
# (the broader `npx @next/codemod@canary upgrade latest` also performs this step)It renames the file and the exported function (middleware → proxy). Do it manually if you prefer:
mv middleware.ts proxy.ts (and rename the function to proxy, even with a default export).skipMiddlewareUrlNormalize → skipProxyUrlNormalize (the codemod does this too).proxy runs on Node.js only; setting runtime throws. If your code genuinely depends on the Edge Runtime, keep using middleware.ts for now (it still works with a deprecation warning) — Vercel has signalled follow-up Edge guidance in a later minor.| Anti-pattern | Why it's wrong | Fix |
|---|---|---|
Treating middleware.ts as the current convention in a Next.js 16 app | The current convention is proxy.ts; middleware.ts is deprecated | Run the codemod; teach the current file/function name |
No matcher — runs on /_next/static, /_next/image, /favicon.ico, metadata files | Adds latency to every image fetch and static asset | Set a matcher with negative lookahead excluding _next paths, assets, and metadata files |
| Matcher excludes prefetch or transport variants without tests | Protected content may be reachable through a request shape the developer didn't consider | Test matcher coverage; enforce authz in the underlying page/route/action |
| Treating the proxy/middleware auth gate as the security boundary | Bypassable (CVE-2025-29927) and silently skipped by matcher changes | Enforce authz at the route / Server Action / Data Access Layer; keep proxy as a UX redirect only |
| Trusting inbound framework-internal or CSP headers | Users can supply headers meant to be generated internally (e.g. x-middleware-subrequest, inbound Content-Security-Policy) | Strip/distrust those headers at the edge when an advisory says they are sensitive |
Using request.geo / request.ip | Removed from NextRequest in Next.js 15 — now undefined | Use geolocation() / ipAddress() from @vercel/functions, or parse platform geo headers |
Buffer.from(...) for a nonce in middleware.ts | Buffer is Node-only — breaks the Edge build | Use btoa(crypto.randomUUID()) (works on Edge and Node) |
Shipping both middleware.ts and proxy.ts | Unstable, undefined behavior | Keep exactly one; delete the other |
Setting export const config = { runtime: 'edge' } in proxy.ts | Throws — proxy is Node.js only | Keep middleware.ts if you truly need Edge |
| Per-request database query or uncached remote auth lookup | Network round-trip on every request (even where Node makes it possible) | Use signed cookies that carry the data, cached keys, or push the lookup to the route |
| Per-route business logic in the proxy layer | Centralized file that hides the logic from the route that owns it | Move to the route; keep this layer for genuine cross-cutting |
Importing a Node-only package in middleware.ts | Edge build fails | Use Edge-compatible alternatives, move to proxy.ts (Node), or move the work to a Route Handler |
redirect when rewrite was meant (or vice versa) | URL changes when it shouldn't, or stays the same when it should | Choose based on whether the user should see the URL change |
Rewriting with a hand-rolled fetch() instead of NextResponse.rewrite() | Drops/mishandles the internal RSC/Flight headers Next.js propagates; the route serves a wrong/stale RSC payload on soft client-side navigation | Use NextResponse.rewrite(); layer only your own headers on top |
| Nonce-based CSP everywhere by default | Forces dynamic rendering; undermines static generation / CDN cache / ISR / PPR | Use static next.config headers or SRI/hash CSP when a per-request nonce isn't required |
Importing cookies() from next/headers inside proxy/middleware | cookies() is for render-time route code; it reads the wrong context here | Read with request.cookies.get(...); write with response.cookies.set(...) |
Modifying request headers without NextResponse.next({ request: { headers } }) | Request-side changes don't reach the route | Use the request.headers form; set on response.headers too if the client needs to see them |
| Setting cookies on the request | Cookie set silently lost | Set cookies on the response via response.cookies.set(...) |
Blanket-forwarding all inbound headers upstream (cloning request.headers wholesale onto an outgoing fetch/rewrite target) | User-controlled, security, or framework-internal headers can leak or alter downstream behavior, or exceed header-size limits | Copy only the specific headers you own/need onto the outgoing request |
| Running the layer on webhook routes | Proxy buffers the body up to proxyClientMaxBodySize; large raw payloads can exceed it, and redirects/header changes/auth gates can interfere with exact webhook delivery and HMAC verification | Exclude webhook paths from the matcher; verify the HMAC in the route handler off the raw body |
Assuming a negative matcher removed /_next/data coverage | /_next/data runs even when excluded (by design) | Don't rely on the exclusion for data routes; account for them |
| Single 100-line proxy doing 8 things | Untestable, slow, hard to reason about | Decompose into named helpers; unit-test with next/experimental/testing/server; move some concerns per-route |
After applying this skill, verify:
proxy.ts with a proxy function; middleware.ts is used only when the Edge Runtime is genuinely required, and the two files never coexist. If migrating, the codemod (or equivalent) renamed the file, the exported function, and middleware-named config flags.config.matcher is set with build-time constants and excludes _next/static, _next/image, favicon.ico, metadata files (sitemap.xml, robots.txt), and any other paths that don't need the layer (typically webhooks); /_next/data coverage is accounted for.next.config redirects/headers, a static CSP, and the platform WAF/bot-management layer — before this layer was used.fetch/rewrite.proxy.ts (Node.js) where it is technically possible.@vercel/functions (or platform headers), handles local/undefined values, and is not treated as a security boundary — not the removed request.geo/request.ip.middleware.ts (Edge), all imports are Edge-compatible (no Node crypto, fs, Buffer, child_process, net); on proxy.ts, no runtime config is set.request.cookies and written via response.cookies.set — cookies() from next/headers is NOT imported here; cookies that need to reach the client are set on the response, not the request.NextResponse.next({ request: { headers } }) so they flow to the route; response headers the browser needs are set on the response.rewrite, redirect, direct response, and next() matches whether the URL should visibly change and the HTTP semantics; static cases were checked against next.config redirects/headers first; rewrites use NextResponse.rewrite() (not a hand-rolled fetch()) so RSC/Flight headers survive soft navigation.waitUntil is used only for non-critical side effects.next/experimental/testing/server.security-fundamentals.proxy.ts convention, runtime, matcher, response API, waitUntil, version history, and testing utilities.NextRequest. Cookies, nextUrl, and the ip/geo removal history.cookies(). The scope of the next/headers cookie helper (Server Components, Server Functions, Route Handlers — not the request boundary).POST under the hood and require action-owned authorization.geo and ip from NextRequest (#68379) and Vercel KB — geo/IP headers with Vercel Functions. The Next.js 15 geo/ip removal and the @vercel/functions replacement.x-middleware-subrequest mitigation.middleware.ts runs against, and the per-plan code-size limit (after gzip: 1 MB Hobby / 2 MB Pro / 4 MB Enterprise).waitUntil. The Web-standard interfaces underlying NextRequest/NextResponse and NextFetchEvent.security-fundamentals).proxyClientMaxBodySize config. The body-buffering size ceiling that governs whether proxy can sit in front of large/raw payloads (e.g. webhooks).| Instead of this skill | Use | Why |
|---|---|---|
| Per-route HTTP endpoint logic — JSON APIs, webhook handlers, streaming responses | route-handler-design | Route Handlers own per-route per-method logic; this layer owns cross-cutting preprocessing. |
| Internal mutations triggered from this app's UI | server-actions-design | Server Actions are the in-app mutation surface; the proxy layer sits upstream of them but does not replace them — and must not be their only auth gate. |
| Understanding what each HTTP status or method means in the abstract | http-semantics | http-semantics owns the protocol; this skill owns honoring it in the proxy layer. |
| Designing the full Content Security Policy, the authorization architecture, or the broader hardening discipline | security-fundamentals | security-fundamentals owns the policy and the security boundary; this layer is one delivery surface and a UX gate. |
| The cross-cutting streaming model (Web Streams, SSE, backpressure) | streaming-architecture | This layer can set headers for streamed responses but does not author the streaming logic. |
The serialization/directive mechanics of 'use client' and 'use server' | client-server-boundary | Different boundary — that's the bundler component split, not the HTTP request edge. |
| The full webhook reliability story (HMAC verification, idempotency, retries) | webhook-integration | Webhook routes should generally be excluded from this layer; their handler owns the reliability discipline. |
<!-- skill-graph-context:start (generated — do not edit by hand) -->
Classification
frontend-engineeringtrueengineering/frontendproxy.ts since Next.js 16, function proxy, Node.js runtime; or the deprecated-but-retained middleware.ts, function middleware, Edge Runtime by default with a Node.js opt-in since 15.5) that runs cross-cutting request/response transforms before route resolution. Covers the proxy↔middleware runtime split and its capability consequences, the matcher config (including the _next/data always-runs and Server-Function-coverage gotchas), the NextRequest/NextResponse API (cookies/headers; geolocation()/ipAddress() from @vercel/functions after the Next.js 15 geo/ip removal), the four response shapes (next/rewrite/redirect/direct), waitUntil background work, the official next/experimental/testing/server utilities, the canonical patterns (auth gate, locale routing, A/B testing, header injection, geo-routing, bot blocking), the per-matched-request performance cost, the CVE-2025-29927 lesson that this layer is not a security boundary, and the rule that it is for cross-cutting concerns, never per-route business logic. Portable across Next.js App Router projects; principle-grounded, not repo-bound. Excludes per-route endpoint logic (route-handler-design), the Server Action surface (server-actions-design), abstract HTTP semantics (http-semantics), CSP and hardening (security-fundamentals), and the streaming model (streaming-architecture).When to use
how do I redirect unauthenticated users to login in Next.js, how do I run code before every request in Next.js, should I use middleware.ts or proxy.ts in Next.js 16, how do I migrate middleware.ts to proxy.ts, how do I set security headers globally in Next.js, how do I do locale routing in App Router, why does my middleware run on static assets, can middleware do a database query, how do I read geo or IP in Next.js middleware now, is Next.js middleware a security boundaryNot for
server-actions-design: the internal-mutation surface invoked from the app's own UIserver-components-design: the render pathRelated skills
code-review, security-fundamentalsroute-handler-design, server-actions-design, http-semantics, security-fundamentals, server-components-design, client-server-boundary, webhook-integrationConcept
~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.