Skip to content
Chapter 33Lesson 2

proxy.ts and the matcher

Next.js 16's proxy.ts runs before your routes; the matcher controls what it costs.

A request for /dashboard arrives. Before the page renders, two things should happen: a signed-out visitor gets bounced to /sign-in instead of glimpsing the dashboard, and a request for an old URL like /billing/old/invoices quietly lands on its new home. Neither is the dashboard’s job, and both have to happen before the route runs.

Last lesson you read cookies and headers inside the render, with cookies() and headers(). This lesson is about the channel that runs before it: a single file that can inspect, redirect, or reshape a request before any route code executes. You’ll leave with three things: the proxy.ts convention (once called middleware.ts), the matcher that controls what it costs you, and a rule for what belongs in that file versus the route.

Next.js 16 renamed this file. What was middleware.ts exporting a middleware function is now proxy.ts exporting a proxy function. The new name is the point: it carries the mental model the file needs.

“Middleware” sets the wrong expectation. In Express and similar frameworks, middleware is a chain of per-request handlers stacked in front of your app, and the instinct it invites is that this is where per-request logic lives: parse the body, hit the database, run business rules. That instinct is wrong for this file.

“Proxy” names what the file is: a network proxy sitting at the boundary, before the request reaches any route. It does one of three things: short-circuit the request with a redirect or response, rewrite it to a different internal route, or pass it through untouched. It is a fast gate, not a second application layer, and the framework treats it as a last resort. When you reach for it, first ask whether a route-level pattern would do the job instead.

You will still meet the old name constantly: middleware.ts and export function middleware appear in every pre-16 codebase and in most AI snippets. Read them as the former name for this same file. The two tabs below are one proxy under both names; flip between them and only the filename and function name move.

proxy.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export default function proxy(request: NextRequest) {
return NextResponse.redirect(new URL('/sign-in', request.url));
}

The 2026 shape. The function name matches the filename, proxy. The rest is the request-shaping API the lesson unpacks.

Migrating an existing codebase is one command. Next.js ships a codemod that renames both the file and the function:

Terminal window
npx @next/codemod@canary middleware-to-proxy .

It handles the mechanical rename but isn’t exhaustive: custom imports or Edge-runtime-specific code may need a manual pass, so read the diff rather than trust it blindly.

In Next.js 16 proxy.ts runs on the Node.js runtime, the same full environment as the rest of your app. This is not configurable: setting the runtime option in a proxy file makes Next.js throw. The previous generation of this file ran on the Edge runtime , a separate environment with its own restricted slice of the API surface; for new code in 2026 you can set that name aside. The payoff is that the proxy uses the same APIs and packages and has the same cold-start behavior as everything else, so you reason about one set of capabilities instead of two.

One fact shapes how you write the file, and the rest of the lesson keeps returning to it:

With no matcher configured, “every request” is literal: not just your pages but every static chunk, optimized image, public/ file, and favicon fetch. Each one now pays a trip through your proxy function, adding latency to assets that have nothing to do with auth or rewrites, and on a platform that bills per invocation, adding cost too. This regression breaks nothing and never surfaces in a code review, so it quietly slows every page and grows the bill until someone goes looking.

So before the matcher fixes that, here is where the proxy sits and what it can do to a request.

flowchart LR
  req([Client request])
  matcher{"Matcher:<br/>does this path<br/>match?"}
  proxy["<b>proxy() runs</b>"]

  short["<b>Short-circuit</b><br/>NextResponse.redirect()<br/>or a direct Response<br/><i>route never runs</i>"]
  rewrite["<b>Rewrite</b><br/>NextResponse.rewrite()<br/><i>different internal route<br/>renders, URL unchanged</i>"]
  pass["<b>Pass through</b><br/>NextResponse.next()<br/><i>matched route renders,<br/>optionally with added headers</i>"]

  route(["Route renders"])

  matcher -- No --> route
  matcher -- Yes --> proxy
  proxy --> short
  proxy --> rewrite
  proxy --> pass
  pass --> route

  req --> matcher

  class req,matcher edge
  class proxy proxy
  class short,rewrite stop
  class pass go
  class route route
  classDef edge fill:#1f2937,stroke:#94a3b8,color:#f8fafc
  classDef proxy fill:#dbeafe,stroke:#1d4ed8,color:#111,stroke-width:2px
  classDef stop fill:#fee2e2,stroke:#b91c1c,color:#111
  classDef go fill:#bbf7d0,stroke:#15803d,color:#111
  classDef route fill:#fef9c3,stroke:#a16207,color:#111,stroke-width:2px
Only the matched branch pays the proxy. An unmatched request reaches its route without ever entering `proxy()`, which is why the matcher, not the function body, is the first thing to tune.

The three terminals on the right are the three things a proxy can do, the three jobs we’ll keep returning to. For cost, the edge that matters is on the left: the No branch skips the proxy entirely, and much of this lesson is about sending as much traffic as possible down that No branch.

The matcher is a config export sitting next to your proxy function, and it answers one question: which paths does the proxy run on? It decides whether the proxy is invisibly cheap or an invisible tax. It comes in a few forms, ordered below from simplest to most expressive.

The simplest is a single path string:

export const config = {
matcher: '/dashboard/:path*',
};

That pattern is path-to-regexp syntax, the same shape Next.js uses for routes. The piece to recognize is :path*: a named segment plus a modifier. The * means zero or more segments, so /dashboard/:path* matches /dashboard, /dashboard/settings, and /dashboard/team/billing alike. The other modifiers (?, +, plain :path) follow the same shape; look them up when you need a precise one.

When the proxy guards more than one section of the app, you pass an array of strings:

export const config = {
matcher: ['/dashboard/:path*', '/settings/:path*'],
};

This is the everyday “run on these app sections” form: it reads cleanly and extends easily.

The form you’ll copy most often is the negative-lookahead regex, the canonical way to say “run on everything except assets”:

export const config = {
matcher: '/((?!api|_next/static|_next/image|favicon.ico).*)',
};

You recognize it and adjust the exclusion list rather than read it character by character. The proxy’s default is to match everything, including all those assets. This pattern inverts the problem: instead of listing the paths you want, it matches every path and then carves out the ones you don’t. The (?!...) is a negative lookahead : “match here only if what follows is not one of these.” So api, _next/static, _next/image, and favicon.ico fall through to their routes untouched, and everything else runs through the proxy.

The most expressive form is the object form, which adds predicate clauses on top of the path:

export const config = {
matcher: [
{
source: '/((?!api|_next/static|_next/image|favicon.ico).*)',
missing: [{ type: 'cookie', key: '__Host-session' }],
},
],
};

source is the path pattern. has and missing gate on the presence or absence of a cookie, header, or query value, where type is 'cookie' | 'header' | 'query'. The example reads “run on these paths, but only when the session cookie is missing” — exactly the shape of an auth gate, since a request that already carries the cookie doesn’t need the proxy to bounce it. has is the inverse. The matcher does the cheap gating itself, so the proxy body never runs on a request it would have nothing to do for.

Two facts about the matcher aren’t obvious, and each has cost people real debugging time.

First, the matcher must be statically analyzable. Next.js reads its value at build time, not per request, so a matcher assembled from a runtime variable — a path pulled from an environment lookup or computed in a function — is silently ignored. No error, no warning; it just doesn’t match what you think. Keep it a literal.

Second, excluding a path from the matcher also stops the proxy running on Server Action POSTs to that path, and the next section depends on this. Server Actions submit to the route they live under, so if your matcher carves out /api and an action posts there, the proxy never sees it. That’s why the framework’s guidance is blunt: never lean on the proxy alone for auth. The real check belongs inside each Server Action and route.

Here is the production matcher most apps converge on, walked one clause at a time.

export const config = {
matcher: [
{
source: '/((?!api|_next/static|_next/image|favicon.ico).*)',
missing: [{ type: 'cookie', key: '__Host-session' }],
},
],
};

The path pattern. The negative lookahead matches every path except the API routes and static-asset folders, so the proxy never runs on a JS chunk or an optimized image. This one line is the cost control.

export const config = {
matcher: [
{
source: '/((?!api|_next/static|_next/image|favicon.ico).*)',
missing: [{ type: 'cookie', key: '__Host-session' }],
},
],
};

The predicate gate. missing runs the proxy only when the named cookie is absent, so a request that already has a session cookie skips the function entirely. has is the inverse. The matcher does the cheap filtering before any of your code executes.

export const config = {
matcher: [
{
source: '/((?!api|_next/static|_next/image|favicon.ico).*)',
missing: [{ type: 'cookie', key: '__Host-session' }],
},
],
};

The whole value is read at build time, so it must be a static literal. A matcher built from a runtime variable is silently ignored, with no error to point you at the problem.

1 / 1

The fastest way to internalize the cost model is to sort a few real requests. For each one, decide whether the matcher should select it (the proxy needs to run) or exclude it (the proxy is dead weight). The heuristic: app pages get selected, assets and most API routes get excluded.

Should the proxy run on this request? Everything in the exclude column is latency and invocation cost you'd pay for nothing without a tight matcher. Drag each item into the bucket it belongs to, then press Check.

Matcher should select it The proxy needs to run here
Matcher should exclude it Dead weight through the proxy
GET /dashboard
GET /settings/billing
A POST to the sign-in route
GET /_next/static/chunk.js
An <img> request for /public/logo.png
GET /api/health

App pages get selected, because that’s where the auth gate, the rewrite, or the header enrichment lives; assets and constantly-hit API routes get excluded. The sign-in POST is the one that catches people: it’s an app page the proxy may still need to act on (an unauthenticated user belongs there), so it stays selected.

What belongs in the proxy, and what doesn’t

Section titled “What belongs in the proxy, and what doesn’t”

You’ve seen what the proxy can do and what it costs. Here’s the line: exactly four jobs belong in it.

Gating on auth. Bounce signed-out requests cheaply. Check that a session cookie is present, and if it isn’t, redirect to /sign-in?next=.... This fast bounce keeps the user from seeing a flash of a protected page; it does not decide whether the session is genuinely valid.

Rewriting and redirecting. Handle URL migrations (the old /billing/old/* paths) and internal route swaps. The next lesson covers how these work.

Enriching the request. Derive something cheap once and set it as a header the downstream route reads back via headers(). This is the proxy-to-route pattern, covered shortly.

Routing flags and A/B tests. Bucket a user by writing a cookie the proxy controls, then let downstream routes read that cookie and branch on it.

These share a shape: each is cheap, each is about the request as it enters, and none is the real work of the page. That shape also defines what doesn’t belong. Because the proxy is a network boundary and not your application, three things stay out of it.

No database queries on every request. A DB read in the proxy isn’t paid once; it’s paid on every matched request, multiplied across your whole app. This is the textbook “why did every page get slow” regression, invisible until you profile.

No complex business logic. The proxy runs separately from your render code, so a bug that straddles the proxy/route boundary is painful to debug, and sharing app modules or globals across that boundary couples two things the framework deliberately keeps apart. Rule of thumb: if a piece of logic is interesting enough to need a test, it belongs in the route.

No authoritative auth check. This is the one people get wrong, so be precise. The proxy checks cookie presence; the route’s requireUser() validates against the database. Two reasons it has to work this way, both already familiar. First, the Server Action trap from the last section: a refactor that adjusts the matcher can silently drop proxy coverage on an action’s path, and if the proxy were your only guard, that refactor just opened a hole. Second, session caching: Better Auth keeps a short-lived cache of the decoded session, a few minutes, to avoid a database hit on every request, so the proxy can read a stale session for minutes after a sign-out or a role change. A gate that can be minutes out of date cannot authorize a sensitive action.

So this isn’t redundancy, it’s defense in depth. The proxy is a UX optimization, a fast bounce that keeps signed-out users from ever rendering a protected page. The route is the security boundary, where the real decision is made, against fresh data, every time. Different jobs that happen to look alike from the outside.

The sentence to carry out of this lesson: do the cheap thing in the proxy, do the authoritative thing in the route.

Run a candidate piece of logic through the questions below, in the order an experienced engineer asks them.

Does this belong in proxy.ts?

Don’t memorize this API. Aim for recognition: open a proxy file, know what request gives you and what you can return, and look up the exact method when you need it.

Your proxy function receives a NextRequest — the platform Request you already know, with a few Next.js conveniences on top:

  • request.nextUrl: a parsed URL object. Reach here for .pathname and .searchParams instead of hand-parsing request.url.
  • request.cookies: a RequestCookies store with get, getAll, has, set, and delete. get returns { name, value } or undefined.
  • request.headers: the same web-platform Headers instance you’ve used before.

One correction, because the old shape is everywhere. Geolocation and client IP used to live on request.geo and request.ip. Those were removed. On Vercel you now import them as functions — geolocation(request) and ipAddress(request) from @vercel/functions . Off Vercel, read whatever header your platform documents. A snippet with request.geo or request.ip is pre-15 code, and it won’t run in 16.

Your return value shapes the reply. There are four shapes: the three terminals from the lifecycle diagram, plus the direct-response escape hatch.

  • NextResponse .next(): pass through, so the matched route renders. “I looked, I’m done, carry on.”
  • NextResponse.redirect(url, status?): short-circuit with a 3xx, so the route never runs. (Status codes and redirect-versus-rewrite semantics are the next lesson; here it just bounces the request.)
  • NextResponse.rewrite(url): render a different internal route while the visible URL stays put. (Depth in the next lesson.)
  • Response.json(body, { status }) or new NextResponse(body, { status }): answer directly, like a 401 for an API path. Rare in proxy.ts, common in route handlers.

Build one habit early: there is no implicit pass-through. A path that doesn’t return — or doesn’t return NextResponse.next() — leaves the request hanging. Every branch must return something.

Here’s the whole surface in one small proxy. Hover the highlighted parts to probe what each gives you: read the URL, read a cookie, branch, and return. (This reads the cookie directly to show the request.cookies API; the worked example at the end swaps in the proper Better Auth helper for the real gate.)

proxy.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export default function proxy(request: NextRequest) {
const { pathname } = request.nextUrl;
const session = request.cookies.get('__Host-session');
if (pathname.startsWith('/dashboard') && !session) {
return NextResponse.redirect(new URL('/sign-in', request.url));
}
return NextResponse.next();
}

One last point about robustness, since the proxy sits in front of everything. A throw inside the proxy doesn’t fail one request — it returns a 500 for every matched request until you fix it. Wrap anything that can fail in a try/catch and pass through on the error path:

try {
// risky derivation
} catch {
return NextResponse.next();
}

Note the direction. The course’s general rule is that an exception inside a gate is a refusal: you fail closed. The proxy is the opposite. Because it’s a non-authoritative gate and the route still enforces real auth, the safe default is to fail open — let the request reach the route, which does the genuine check. Failing closed here would take the whole app down over a transient error in a check the route is going to repeat anyway.

Sometimes the proxy derives a cheap value, reading a cookie or resolving something, and you’d rather the route reuse that result than recompute it. The proxy can hand it forward as a request header: clone the incoming headers, set your value on the clone, and forward them through NextResponse.next. The route reads the value back with headers() and never redoes the work.

Three traps surround this, and the first matters most.

The second trap is security. Don’t reflexively clone all incoming headers onto the forwarded request. An attacker can send their own x-user-id from outside, and if you pass it through untouched, your route trusts a value the client made up. The rule is an allow-list : set only the identity headers you derived yourself inside the proxy, and never forward a client-supplied one as if it were trusted.

The enrichment proxy below puts all three pieces together.

import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export default function proxy(request: NextRequest) {
const requestHeaders = new Headers(request.headers);
requestHeaders.set('x-user-id', deriveUserId(request));
return NextResponse.next({
request: { headers: requestHeaders },
});
}

Clone the incoming headers into a mutable copy. You’re building the set the route will see, starting from what arrived.

import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export default function proxy(request: NextRequest) {
const requestHeaders = new Headers(request.headers);
requestHeaders.set('x-user-id', deriveUserId(request));
return NextResponse.next({
request: { headers: requestHeaders },
});
}

Set only the header you derived yourself. This is the allow-list discipline: never forward a client-supplied x-user-id, or the route would trust a value the caller invented. (deriveUserId stands in for a cheap derivation; in production this is where Better Auth resolves the session.)

import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export default function proxy(request: NextRequest) {
const requestHeaders = new Headers(request.headers);
requestHeaders.set('x-user-id', deriveUserId(request));
return NextResponse.next({
request: { headers: requestHeaders },
});
}

The request: wrapper is everything. next({ request: { headers } }) sets headers the route reads via headers(). Drop the wrapper, writing next({ headers }), and you’d instead be setting headers on the response to the client. Same-looking call, opposite effect.

1 / 1

The route reads it back with the same headers() API from last lesson, and never re-derives the value:

the route
const userId = (await headers()).get('x-user-id');

The third trap is cookies, the same proxy-to-route theme from the other side. Setting a cookie on the response with response.cookies.set(...) does not make the current request’s cookies() read see it. A cookie is an instruction to the browser, and the browser only sends it back on the following request, the same model from last lesson where the server reads what the browser sent and tells it what to store next.

Here is the production shape in one file. This proxy does two of the four jobs: it gates the app behind a session cookie and passes everything else through. It stays under thirty lines and leans on two black boxes the rest of the course fills in.

Two pieces come from elsewhere. SESSION_COOKIE_PREFIX and the route’s requireUser() both arrive from the authentication chapters; they are imported here as known quantities, not reimplemented, so the proxy is the slot and the auth wiring lands later. The other piece is the next= round-trip, which returns the user to where they came from after sign-in: it is named here but not yet validated, and closing the open-redirect hole on that parameter is the next lesson’s subject.

proxy.ts
import { getSessionCookie } from 'better-auth/cookies';
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { SESSION_COOKIE_PREFIX } from '@/lib/auth';
export default function proxy(request: NextRequest) {
const hasSession = getSessionCookie(request, {
cookiePrefix: SESSION_COOKIE_PREFIX,
});
if (!hasSession) {
const signIn = new URL('/sign-in', request.url);
signIn.searchParams.set('next', request.nextUrl.pathname);
return NextResponse.redirect(signIn);
}
return NextResponse.next();
}
export const config = {
matcher: '/((?!api|_next/static|_next/image|favicon.ico).*)',
};

The matcher first. It gates the app and excludes API routes and assets, so the proxy never runs on a JS chunk or an image. Cost control before anything else.

proxy.ts
import { getSessionCookie } from 'better-auth/cookies';
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { SESSION_COOKIE_PREFIX } from '@/lib/auth';
export default function proxy(request: NextRequest) {
const hasSession = getSessionCookie(request, {
cookiePrefix: SESSION_COOKIE_PREFIX,
});
if (!hasSession) {
const signIn = new URL('/sign-in', request.url);
signIn.searchParams.set('next', request.nextUrl.pathname);
return NextResponse.redirect(signIn);
}
return NextResponse.next();
}
export const config = {
matcher: '/((?!api|_next/static|_next/image|favicon.ico).*)',
};

A cookie presence check, nothing more: getSessionCookie reads the session cookie without validating it. We pass SESSION_COOKIE_PREFIX because the helper defaults to 'better-auth.' and would silently miss our __Host- prefix. Sharing the exported constant keeps the proxy and the auth config from drifting apart.

proxy.ts
import { getSessionCookie } from 'better-auth/cookies';
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { SESSION_COOKIE_PREFIX } from '@/lib/auth';
export default function proxy(request: NextRequest) {
const hasSession = getSessionCookie(request, {
cookiePrefix: SESSION_COOKIE_PREFIX,
});
if (!hasSession) {
const signIn = new URL('/sign-in', request.url);
signIn.searchParams.set('next', request.nextUrl.pathname);
return NextResponse.redirect(signIn);
}
return NextResponse.next();
}
export const config = {
matcher: '/((?!api|_next/static|_next/image|favicon.ico).*)',
};

No cookie on a protected path, so bounce to /sign-in, stashing the intended path in next= so sign-in can return them. (That value is validated next lesson; redirecting on a raw value is an open-redirect risk.)

proxy.ts
import { getSessionCookie } from 'better-auth/cookies';
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { SESSION_COOKIE_PREFIX } from '@/lib/auth';
export default function proxy(request: NextRequest) {
const hasSession = getSessionCookie(request, {
cookiePrefix: SESSION_COOKIE_PREFIX,
});
if (!hasSession) {
const signIn = new URL('/sign-in', request.url);
signIn.searchParams.set('next', request.nextUrl.pathname);
return NextResponse.redirect(signIn);
}
return NextResponse.next();
}
export const config = {
matcher: '/((?!api|_next/static|_next/image|favicon.ico).*)',
};

A cookie is present, so pass the request through to the route. Every branch returns; there is no implicit fall-through.

proxy.ts
import { getSessionCookie } from 'better-auth/cookies';
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { SESSION_COOKIE_PREFIX } from '@/lib/auth';
export default function proxy(request: NextRequest) {
const hasSession = getSessionCookie(request, {
cookiePrefix: SESSION_COOKIE_PREFIX,
});
if (!hasSession) {
const signIn = new URL('/sign-in', request.url);
signIn.searchParams.set('next', request.nextUrl.pathname);
return NextResponse.redirect(signIn);
}
return NextResponse.next();
}
export const config = {
matcher: '/((?!api|_next/static|_next/image|favicon.ico).*)',
};

The one thing to carry away: this checks presence. The route’s requireUser() does the authoritative validation against the database, fresh every time. Presence here, the real check there: defense in depth.

1 / 1

One detail is worth calling out: the default export. proxy.ts is a framework-named file where Next.js dictates the export style, so it is a carve-out from the project’s usual named-export rule. Next.js accepts either a default export or a named export function proxy; this course uses the default.

That is the production slot. The authentication chapters fill in the real session wiring behind requireUser(), and the next lesson adds the rewrite and redirect jobs to this same file.

The proxy surface moves fast, and it was renamed in this major version, so prefer the official docs as the source of truth over anything older.