Proxy (Middleware) and the Request Lifecycle
The idea in one sentence
Section titled “The idea in one sentence”A proxy.ts file at the root of your project runs BEFORE a request finishes, on every matching route, so you can redirect, rewrite, or set headers and cookies before Next.js renders anything — Next.js 16 renamed this file from middleware.ts to proxy.ts.
Where proxy sits in the request lifecycle
Section titled “Where proxy sits in the request lifecycle”Proxy intercepts the request after it arrives but before the route is resolved. You export a function named proxy (or a default export) and return a NextResponse to redirect, rewrite, or continue with NextResponse.next().
// proxy.ts — runs BEFORE the request completes, on every matching routeimport { NextResponse } from 'next/server'import type { NextRequest } from 'next/server'
export function proxy(request: NextRequest) { // Lightweight auth gate: redirect logged-out users away from /dashboard const isLoggedIn = request.cookies.has('session') if (request.nextUrl.pathname.startsWith('/dashboard') && !isLoggedIn) { return NextResponse.redirect(new URL('/login', request.url)) }
// Otherwise continue, and set a header on the way through const response = NextResponse.next() response.headers.set('x-request-time', Date.now().toString()) return response}Proxy also runs before every entry in the Next.js execution chain — next.config.ts headers/redirects run first, then proxy, then filesystem routes and rewrites. One consequence that trips people up: Server Actions are not separate routes in that chain — they are POST requests to the page where they’re used, so a matcher that excludes a path also silently skips every Server Action call on that path.
NextResponse.next() vs redirect() vs rewrite()
Section titled “NextResponse.next() vs redirect() vs rewrite()”Proxy can produce a response in three different ways, and each means something different for the browser and for your app:
| Call | What the browser sees | What runs next |
|---|---|---|
NextResponse.next() | Nothing — the request continues normally | The matched page or Route Handler renders |
NextResponse.redirect(url) | A new URL in the address bar (3xx response) | A brand-new request to url |
NextResponse.rewrite(url) | The original URL stays in the address bar | The content at url renders instead — same URL, different content |
Use redirect when the user should end up somewhere else (send them to /login). Use rewrite for A/B tests or geo routing, where the URL should stay the same but the served content should change:
// A/B or geo rewrite — same URL, different content servedimport { NextResponse } from 'next/server'import type { NextRequest } from 'next/server'
export function proxy(request: NextRequest) { const country = request.headers.get('x-vercel-ip-country') ?? 'us' return NextResponse.rewrite(new URL(`/${country}${request.nextUrl.pathname}`, request.url))}Propagating headers from proxy to a Server Component
Section titled “Propagating headers from proxy to a Server Component”Proxy can hand information downstream to the page that ends up rendering — the trick is which argument you set it on. NextResponse.next({ request: { headers } }) makes the header available to your app’s render path; NextResponse.next({ headers }) only sets it on the response that goes back to the browser. Mixing the two up is an easy, silent mistake, because both compile and both “work” — just for different audiences.
// proxy.ts — forward a header to the render path, not just to the browserimport { NextResponse } from 'next/server'import type { NextRequest } from 'next/server'
export function proxy(request: NextRequest) { const requestHeaders = new Headers(request.headers) requestHeaders.set('x-hello-from-proxy', 'hello')
// request.headers reaches the Server Component via headers() return NextResponse.next({ request: { headers: requestHeaders } })}// app/page.tsx — read the header proxy forwardedimport { headers } from 'next/headers'
export default async function Page() { const h = await headers() return <div>{h.get('x-hello-from-proxy')}</div>}Proving it in the probe (next build && next start, then reading everything headers() sees on the server) shows the forwarded header landing right next to the framework’s own x-forwarded-* headers:
host=localhost:3416|user-agent=curl/8.18.0|accept=*/*|x-forwarded-host=localhost:3416|x-forwarded-port=3416|x-forwarded-proto=http|x-forwarded-for=::1|x-hello-from-proxy=helloProxy always runs on the Node.js runtime, and that is not configurable — exporting runtime from proxy.ts is a build error. It still sits in front of every matching request, so keep it fast: no database calls, no heavy computation, no large dependencies (and fetch’s cache/next.revalidate/next.tags options have no effect inside proxy). Do the lightweight decision here and push real work to the route.
Matcher pitfalls: static assets and prefetch requests
Section titled “Matcher pitfalls: static assets and prefetch requests”Without a matcher, proxy runs on every request — including _next/static, _next/image, and files in public/. An auth check with no matcher can unintentionally block your own CSS and JS from loading. Scope it with a negative-match pattern:
// proxy.ts — scope which routes run proxyexport const config = { matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],}The second trap is subtler: proxy also runs on prefetch requests, not just the navigation the user actually lands on. <Link> prefetches routes in the background as they enter the viewport, and each of those prefetches goes through proxy too. A Segment Cache prefetch carries the Next-Router-Prefetch header (surfaced by Next.js internally as request metadata); an older-style browser prefetch instead carries the standard purpose: prefetch header. If your proxy runs a side effect (an analytics ping, a write) on every visit, it will fire for links the user only scrolled past:
// Detect a prefetch so proxy doesn't treat it as a real visitexport function proxy(request: NextRequest) { const isPrefetch = request.headers.get('purpose') === 'prefetch' || request.headers.get('next-router-prefetch') !== null if (isPrefetch) { return NextResponse.next() } // ... real-visit logic}Because Proxy only performs the cheap, optimistic part of a check, it should never be your only line of defense for auth — see Authentication Patterns for why, and where the real check belongs.
Mental Model
Section titled “Mental Model”Think of proxy as a doorperson standing in front of every matching route, not a bouncer who verifies IDs against a database. They can glance at what you’re carrying (cookies, headers, the URL) and wave you toward a different door (rewrite), send you away (redirect), or let you straight through (next()) — all before anyone inside starts doing real work. What they cannot do: check a guest list against a live database without slowing down every single person who walks by, including the ones just peeking through the window (prefetches).
Trade-offs
Section titled “Trade-offs”| What you get | Benefit | Cost |
|---|---|---|
| Proxy with no matcher | Simplest to write, definitely runs everywhere | Also runs on _next/static, _next/image, and every prefetch |
| Proxy with a broad matcher | Fast, centralized redirects for whole route trees | Can accidentally exclude Server Action POSTs on the same path |
NextResponse.next({ request: { headers } }) | Header reaches Server Components via headers() | Easy to typo into { headers }, which silently only reaches the browser |
| Rewrite for A/B / geo routing | URL stays stable for the user and for SEO | The proxy itself must stay fast — no data fetching |
Common Misconceptions
Section titled “Common Misconceptions”- A
matcherthat excludes/apialso protects Server Actions on other paths. It does not — Server Actions are POSTs to the page they’re defined on, so a matcher that excludes a page path also excludes the Server Actions called from that page. NextResponse.next({ headers: h })forwards a header to the Server Component. It only sets a header on the outgoing response to the browser. Forwarding to the render path requires the nested form:NextResponse.next({ request: { headers: h } }).- Proxy only runs when a user actually navigates to a page. It also runs on prefetch requests fired by
<Link>as it enters the viewport — detectable via thepurpose: prefetchornext-router-prefetchheaders. - Proxy can safely do a real permission check against the database. It runs in front of every matching request (including prefetches), so a slow check there taxes every visit; the authoritative check belongs close to the data (see the next lesson).
graph TD
A["Incoming request"] --> B{"Matcher matches?"}
B -->|no| F["Route handler / page (proxy skipped)"]
B -->|yes| C["proxy.ts (Node.js runtime)"]
C -->|redirect| D["NextResponse.redirect — new request"]
C -->|rewrite| E["NextResponse.rewrite — same URL, different content"]
C -->|next with headers| F
F --> G["headers() in Server Component sees forwarded headers"]
This is the abridged view — the full request lifecycle (proxy → routing → RSC render → cache layers → streaming → hydration → client navigation) gets its own end-to-end walkthrough in The Request Lifecycle.
Reading AI-generated code
Section titled “Reading AI-generated code”When a generated snippet touches proxy.ts, check for these before trusting it:
- Does it still use
middleware.ts/export function middleware? That name is deprecated in Next.js 16 — it still works, butnext buildwarns, and generated code trained on older examples reaches for it by default. - Is there a
matcher? If it’s missing, proxy runs on every request, including static assets — often invisible in local dev and only noticed as a production perf/behavior surprise. - When it forwards a header to the app, does it use
NextResponse.next({ request: { headers } }), or does it use the flatterNextResponse.next({ headers })— which looks almost identical but only reaches the browser, never the Server Component? - Does it export a
runtimefromproxy.ts? That’s a build error — proxy is Node.js-only and not configurable. - Does it treat proxy as a place for a database call, a heavy computation, or session verification? That belongs in a Data Access Layer, not proxy — proxy is the optimistic check only.
- Does it assume proxy only fires on “real” navigations? It also runs for every
<Link>prefetch — logic with side effects needs to filter those out explicitly.
🐛 Spot the bug
// proxy.ts — supposed to forward a header to the page that rendersimport { NextResponse } from 'next/server'import type { NextRequest } from 'next/server'
export function proxy(request: NextRequest) {const requestHeaders = new Headers(request.headers)requestHeaders.set('x-hello-from-proxy', 'hello')
return NextResponse.next({ headers: requestHeaders })}
// app/page.tsximport { headers } from 'next/headers'
export default async function Page() {const h = await headers()return <div>{h.get('x-hello-from-proxy')}</div>}Show the answer
NextResponse.next({ headers: requestHeaders }) sets the header only on the response sent to the browser — it never reaches the render path. To make it visible to headers() in a Server Component, it has to be nested under request: NextResponse.next({ request: { headers: requestHeaders } }). Both forms compile and neither throws, so this bug only shows up as h.get('x-hello-from-proxy') silently returning null.