Proxy
proxy.ts is middleware.ts renamed in Next.js 16 — same functionality, clearer name, and a codemod (npx @next/codemod@canary middleware-to-proxy .) to move an existing project over. It now defaults to the Node.js runtime, and setting the runtime segment config throws.
This project’s proxy stamps x-proxy-* headers onto everything it handles, which turns it into a probe: any response carrying a fresh x-proxy-invocation was seen by the origin.
What to check
The headline result. /ssg builds as ○ Static and its body never changes — yet:
for i in 1 2 3; do curl -sI localhost:3000/ssg | grep -i x-proxy-invocation; done
gives three different ids. The page body is cached; the proxy invocation is not. On Pantheon that means a broad matcher puts a per-request origin hop in front of content the CDN could otherwise have served alone.
Prefetch versus navigation. The Flight headers (rsc, next-router-prefetch) are stripped from request.headers, so the proxy function cannot see them. The matcher’s has/missing conditions run earlier and can. Compare:
curl -sI localhost:3000/ssg | grep -i 'x-proxy-branch\|x-proxy-prefetch' curl -sI localhost:3000/ssg -H 'next-router-prefetch: 1' | grep -i 'x-proxy-branch\|x-proxy-prefetch'
Everything else is on the sub-routes below.
Branches in this project's proxy
Respond directly
/api/proxy-guardResponse short-circuits rendering — the route handler never runs. Try curl -si localhost:3000/api/proxy-guard then add -H 'authorization: Bearer let-me-in'.Redirect
/proxy/redirect-me/proxy?redirected-from=…. For a redirect that needs no request data, the redirects key in next.config.ts runs earlier and costs nothing — prefer it.Rewrite
/proxy/rewrite-me/proxy/rewrite-target with the URL unchanged. NextResponse.rewrite forwards the RSC headers for you; a hand-rolled fetch rewrite does not.Cookies and A/B bucketing
/proxy/experimentRequest versus response headers
/proxy/headersNextResponse.next({ request: { headers } }) versus NextResponse.next({ headers }) distinction, read back from both sides.What the proxy cannot do
- No cache invalidation.
revalidateTag,revalidatePathandupdateTagcannot be called here. Invalidation belongs in a Server Action or a Route Handler — see /tags andapp/api/revalidate/route.ts. - No fetch caching.
cache,next.revalidateandnext.tagson afetchhave no effect in Proxy. Anything you fetch here is fetched on every matched request, which is why the docs say Proxy is not for data loading. - No shared state. Proxy may run outside the app’s runtime, so it must not depend on shared modules holding state or on globals. This one imports only the frozen constants in
app/lib/proxy-config.ts. - Not an authorization boundary. A Server Function is a POST to the route that declares it, not a route of its own — so a matcher excluding a path silently skips every action on it. Check authorization inside each action.
- No
runtimeconfig. Node.js only as of v16; settingruntimethrows.
Matcher notes worth remembering
- With no matcher at all, Proxy runs on
_next/static,_next/imageandpublic/too — which is how auth logic ends up blocking your own CSS. - Matcher values must be statically analysable literals. A variable is silently ignored, not an error.
_next/dataroutes are always proxied, even when a negative pattern excludes them — so protecting a page cannot accidentally leave its data route unprotected.- Execution order:
next.configheaders →next.configredirects → Proxy →beforeFilesrewrites → filesystem routes →afterFiles→ dynamic routes →fallback.
Evidence from this request
Static shell
staticran at exec 1uyh72age …
The proxy cannot be observed from inside a cached scope, because the headers it sets are request-time data. That is why the panel beside this one is a dynamic hole.
Prerendered. Its body is frozen, but the response still carried a fresh x-proxy-invocation header — check with curl.
Proxy context
suspense fallbackstreaming at request time — this is what ships in the static shell