Proxy

proxy.ts is middleware.ts renamed in Next.js 16. It works the same, and a codemod (npx @next/codemod@canary middleware-to-proxy .) moves 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, so any response carrying a fresh x-proxy-invocation was seen by the origin.

What to check

The main result. /ssg builds as ○ Static and its body never changes. Even so, this:

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. Next.js strips the Flight headers (rsc, next-router-prefetch) 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 skips rendering, so 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, so 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 and shows three correct ways to render it. It also shows 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. Invalidate from a Server Action or a Route Handler instead. 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 uses it, not a route of its own. A matcher that excludes a path therefore skips every action on it. Check authorization inside each action.
  • No runtime config. Node.js only as of v16; setting runtime throws.

Matcher notes

  • With no matcher at all, Proxy runs on _next/static, _next/image and public/ too. That 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 leave its data route unprotected.
  • Execution order: next.config headers, next.config redirects, Proxy, beforeFiles rewrites, filesystem routes, afterFiles rewrites, dynamic routes, fallback rewrites.

Evidence from this request

Static shell

static

ran at exec id1ymhage …

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.