Skip to content

Proxy (Middleware) and the Request Lifecycle

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.

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 route
import { 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:

CallWhat the browser seesWhat runs next
NextResponse.next()Nothing — the request continues normallyThe 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 barThe 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 served
import { 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 browser
import { 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 forwarded
import { 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=hello

Proxy 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 proxy
export 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 visit
export 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.

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).

What you getBenefitCost
Proxy with no matcherSimplest to write, definitely runs everywhereAlso runs on _next/static, _next/image, and every prefetch
Proxy with a broad matcherFast, centralized redirects for whole route treesCan 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 routingURL stays stable for the user and for SEOThe proxy itself must stay fast — no data fetching
  • A matcher that excludes /api also 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 the purpose: prefetch or next-router-prefetch headers.
  • 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"]
Request lifecycle: proxy runs before the route resolves

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.

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, but next build warns, 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 flatter NextResponse.next({ headers }) — which looks almost identical but only reaches the browser, never the Server Component?
  • Does it export a runtime from proxy.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 renders
import { 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.tsx
import { 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.

When does proxy.ts run?
What is the matcher config used for?
What runtime does proxy use?
A Server Component calls (await headers()).get('x-hello-from-proxy'). What does it get?
// proxy.ts
const requestHeaders = new Headers(request.headers)
requestHeaders.set('x-hello-from-proxy', 'hello')
return NextResponse.next({ headers: requestHeaders })
Which of these belongs in proxy?
A Server Action defined on /dashboard/settings is called from the client. Does proxy run for that call?
export const config = {
  matcher: ['/dashboard/:path*'],
}