Skip to content
Chapter 33Lesson 2

proxy.ts and the matcher

Next.js 16's proxy.ts, the request-boundary file that runs before your routes, and the matcher that controls what it costs.

A request for /dashboard arrives at your server. Before the page renders, you want two things to happen: a signed-out visitor should be bounced to /sign-in instead of seeing a flash of the dashboard, and a request for an old URL like /billing/old/invoices should quietly land on its new home. Neither of those is the dashboard’s job, and both have to happen before the route runs at all.

So where does code that runs ahead of every route live? What does running it on every request cost you? And because you will open older codebases and AI-generated snippets that look slightly different, what name will you see this file under?

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

Next.js 16 renamed this file. What was middleware.ts exporting a middleware function is now proxy.ts exporting a proxy function. That sounds cosmetic, but it isn’t: the new name carries the mental model you need for the file, and getting that model right is most of this lesson.

The word “middleware” brings the wrong expectations. If you’ve touched Express or any similar server framework, middleware means a chain of per-request handlers stacked in front of your application, and the instinct that comes with it is that this is where all my per-request logic goes: parse the body here, hit the database here, run business rules here. That instinct is exactly wrong for this file, and the old name kept inviting it.

“Proxy” names what the file actually is: a network proxy sitting in front of your app. It runs at the boundary, before the request reaches any route, and it can do one of three things: short-circuit the request by sending a redirect or returning a response, rewrite it to a different internal route, or pass it through untouched. It is a fast gate, not a second application layer. The framework now treats it as a tool of last resort, so when you’re tempted to reach for it, the first question is whether a route-level pattern would do the job instead.

You will still meet the old name constantly: middleware.ts and export function middleware show up in every pre-16 codebase and in most snippets an AI hands you. When you see it, read it as the former name for this exact file and nothing more. The two tabs below are the same proxy under both names; flip between them and notice that nothing but the filename and the function name moves.

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. Everything else is the request-shaping API the rest of the lesson unpacks.

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

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

Keep the @canary tag, since that’s what the docs specify for this codemod. It handles the mechanical rename, but it isn’t exhaustive: custom imports or any Edge-runtime-specific code may need a manual cleanup pass afterward, so read the diff rather than trusting it blindly. We’ll see in a moment why the Edge part matters.

In Next.js 16 proxy.ts runs on the Node.js runtime, the full Node.js environment the rest of your app already runs on. This is not configurable: if you set the runtime option in a proxy file, Next.js raises an error. That is a real shift, because 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 the Edge runtime aside once you know the name. The benefit is that you no longer have to reason about two different sets of capabilities: the proxy uses the same APIs and the same packages, and has the same cold-start behavior, as everything else in your app. On Vercel it ships to a fast Node function placed close to your users.

The next point shapes how you write the file, and the rest of the lesson keeps coming back to it:

With no matcher configured, “every request” is literal. It covers not just your pages but every JavaScript chunk Next.js serves from _next/static, every optimized image from _next/image, every file in public/, and every favicon fetch. Each of those now pays a trip through your proxy function. That adds latency to assets that have nothing to do with auth or rewrites, and on a platform that bills per function invocation, it adds cost as well. This kind of regression doesn’t show up in a code review and doesn’t break anything, so it quietly makes every page a little slower and the bill a little bigger until someone goes looking.

Before we fix that with the matcher, let’s pin down where the proxy actually sits and what it can do to a request, so the rest of the lesson has a shared picture to point at.

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, and they map onto the three jobs we’ll keep coming back to. For cost, the edge that matters is on the left: the No branch skips the proxy completely. Much of this lesson is about making sure that No branch carries as much traffic as it should.

The matcher is a config export sitting next to your proxy function, and it answers exactly one question: which paths does the proxy run on? It is the first thing an experienced engineer reaches for, because 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 written in path-to-regexp syntax, the same shape Next.js uses for routes. The piece to recognize is :path*, a named segment with a modifier. The * means zero or more path segments, so /dashboard/:path* matches /dashboard, /dashboard/settings, and /dashboard/team/billing alike. Plain :path is exactly one segment, ? makes a segment optional, and + means one or more. You don’t need to memorize the modifiers; recognize the shape and 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 it’s easy to extend.

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).*)',
};

It looks dense, but you almost never read it character by character: you recognize it and adjust the exclusion list. Here’s the reasoning behind it, which is all you need to hold onto. The proxy’s default is to match everything, including all those assets we just worried about. This pattern inverts the problem: instead of listing the handful of paths you want, it matches every path and then carves out the ones you don’t. The (?!...) is a negative lookahead , meaning “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 without ever touching the proxy, and everything else runs through it.

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 are the new part: they gate on the presence or absence of a cookie, header, or query value, where type is 'cookie' | 'header' | 'query'. The example above reads “run on these paths, but only when the session cookie is missing.” That is exactly the shape you want for an auth gate, because a request that already carries the cookie doesn’t need the proxy to bounce it. has is the inverse: “run only when this is present.” With this form the matcher does the cheap gating itself, so the proxy function 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 at request time. So a matcher assembled from a runtime variable, say a path pulled from an environment lookup or computed in a function, is silently ignored. There’s no error and no warning; it just doesn’t match what you think it matches. Keep the matcher 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, say, /api, and an action posts there, the proxy never sees it. That’s why the framework’s own 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 the 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 would be 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 below, decide whether the matcher should select it (the proxy needs to run) or exclude it (the proxy is dead weight). The heuristic you’re testing is that app pages get selected, while 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

The two columns split on a single heuristic. App pages get selected, because that’s where the auth gate, the rewrite, or the header enrichment lives, while 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”

This is the decision the whole rename was built to make easy. Now that you’ve seen what the proxy can do and what it costs, here’s the line: exactly four jobs belong in it.

Auth gating. Bounce signed-out requests cheaply: check that a session cookie is present, and if it isn’t, redirect to /sign-in?next=.... The proxy is the fast bounce that keeps the user from seeing a flash of a protected page. It is not the thing that decides whether the session is genuinely valid.

Rewrites and redirects. URL migrations (the old /billing/old/* paths) and internal route swaps. We name them here as a job, but the next lesson is entirely about how they work, so we’ll leave the depth there.

Request enrichment. Derive something cheap once and set it as a header that the downstream route reads back via headers(). This is the proxy-to-route pattern, and it gets its own section shortly.

Feature-flag and A/B routing. Bucket a user by writing a cookie the proxy controls, then let downstream routes read that cookie and branch on it.

Notice the shape they share: each is cheap, each is about the request as it enters, and none of them is the real work of the page. That shared shape also defines what doesn’t belong. Because the proxy is a network boundary and not your application, three things do not belong in 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, and it’s 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 genuinely painful to debug. Sharing app modules or globals across that boundary also couples two things the framework deliberately keeps apart. A good rule of thumb: if a piece of logic is interesting enough to need a test, it probably belongs in the route.

Not the authoritative auth check. This is the one that catches people, so it’s worth being precise. The proxy checks cookie presence; the route’s requireUser() does the real validation against the database. There are two reasons it has to work this way, and you’ve met both already. The first is 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. The second is specific to how sessions are cached. 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 be the thing that authorizes 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: the place the real decision is made, against fresh data, every time. They do different jobs that happen to look similar from the outside.

The sentence worth carrying out of this lesson is this: do the cheap thing in the proxy, do the authoritative thing in the route.

The walkthrough below turns that sentence into the order an experienced engineer asks the questions. Run a candidate piece of logic through it.

Does this belong in proxy.ts?

This brings us to the API, which is the one part of the lesson you should not try to memorize. The goal here is recognition: you want to be able to open a proxy file, know what request gives you and what your return value can be, and look up the exact method when you need it.

The argument your proxy function receives is a NextRequest . It’s the platform Request you already know, with a few Next.js conveniences added on top:

  • request.nextUrl: a parsed URL object. This is where you reach 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. Nothing new to learn.

One correction matters, because you’ll see the old shape 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, you read whatever header your platform documents for them. If you see request.geo or request.ip in a snippet, that’s pre-15 code, and it won’t work in 16.

What you return shapes the reply, and there are four shapes: the same three terminals from the lifecycle diagram, plus the direct-response escape hatch.

  • NextResponse.next(): pass through, so the matched route renders. This is the “I looked, I’m done, carry on” return.
  • NextResponse.redirect(url, status?): short-circuit with a 3xx, so the route never runs. (The status codes and redirect-versus-rewrite semantics are the next lesson; here it’s enough that it 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 the request directly, like a 401 for an API path. This is rare in proxy.ts; you’ll see it far more in route handlers.

One habit to build early: there is no implicit pass-through. If a code path through your proxy doesn’t return, or doesn’t return NextResponse.next(), the request hangs. Every branch has to 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. If something inside the proxy throws, it doesn’t just fail that one request; it returns a 500 for every matched request until you fix it. So wrap anything that can fail in a try/catch and pass through on the error path:

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

There’s a subtlety worth naming here. Elsewhere the course’s rule is that an exception inside a gate is a refusal: you fail closed. In the proxy it’s the opposite. Because the proxy is a non-authoritative gate and the route still enforces real auth, the safe default is to fail open, letting the request reach the route, which then does the genuine check. Failing closed in the proxy would instead take your whole app down over a transient error in a check the route is going to repeat anyway.

Here’s the one genuinely new technique in the lesson, and it threads straight back to last lesson’s headers(). 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.

The mechanics are: 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.

That’s the happy path. There are three traps around it, and the first one matters most.

The second trap is security, and it’s a mistake the docs now call out explicitly. Don’t reflexively clone all incoming headers onto the forwarded request. An attacker can send their own x-user-id header from the outside, and if you pass it through untouched, your route will trust a value the client made up. The rule is an allow-list : set only the specific 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. The steps walk the clone, the allow-listed set, and the one line that’s easy to get wrong.

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 about cookies, and it’s the same “state from proxy to route” theme seen 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. It can’t: a cookie is an instruction to the browser, and the browser only sends it back on the following request. This is exactly the model from last lesson, where the server reads cookies the browser sent and instructs the browser to store new ones. The new one shows up on the next round trip, never the current one.

Now to pull it into one production-shaped file. This proxy does two of the four jobs: it gates the app behind a session cookie and passes everything else through. It’s deliberately tight, under thirty lines, and it leans on two black boxes the rest of the course fills in.

Before the code, two things to set expectations. This is the real shape, but it references pieces built elsewhere: SESSION_COOKIE_PREFIX and the route’s requireUser() both come from the authentication chapters later in the course. They’re imported here as known quantities, not reimplemented, so the proxy is the slot and the auth wiring lands later. The other caveat is the next= round-trip, which sends the user back where they came from after sign-in: it’s named here but not 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. This is the 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, a mismatch that would otherwise fail silently.

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).*)',
};

There’s no cookie and the request is for a protected path, so bounce to /sign-in, stashing where they were headed in next= so sign-in can return them. (The next= value is validated in the 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’s 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, which is defense in depth.

1 / 1

One detail in this file is worth calling out. It uses a default export, because proxy.ts is one of the framework-named files where Next.js dictates the export style. That makes it a carve-out from the project’s usual named-export rule. The Next.js docs accept either a default export or a named export function proxy, and you’ll see both in the wild; they’re equivalent. The default export is the shape this course uses.

That’s 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.