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-guard
Returning a Response short-circuits rendering — the route handler never runs. Try curl -si localhost:3000/api/proxy-guard then add -H 'authorization: Bearer let-me-in'.
307 to /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.
Renders /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/experiment
Assigns a bucket cookie, then shows the three correct ways to render it — and why the classic “bucket in middleware, cache the page” bug cannot be written under Cache Components.

Request versus response headers

/proxy/headers
The NextResponse.next({ request: { headers } }) versus NextResponse.next({ headers }) distinction, read back from both sides.

What the proxy cannot do

  • No cache invalidation. revalidateTag, revalidatePath and updateTag cannot be called here. Invalidation belongs in a Server Action or a Route Handler — see /tags and app/api/revalidate/route.ts.
  • No fetch caching. cache, next.revalidate and next.tags on a fetch have 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 runtime config. Node.js only as of v16; setting runtime throws.

Matcher notes worth remembering

  • With no matcher at all, Proxy runs on _next/static, _next/image and public/ 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/data routes are always proxied, even when a negative pattern excludes them — so protecting a page cannot accidentally leave its data route unprotected.
  • Execution order: next.config headers → next.config redirects → Proxy → beforeFiles rewrites → filesystem routes → afterFiles → dynamic routes → fallback.

Evidence from this request

Static shell

static

ran 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 fallback

streaming at request time — this is what ships in the static shell