proxy.ts
Run code before a request reaches a route with proxy.ts: the matcher, rewrites, redirects, headers and cookies, the Node.js runtime, and what to keep out of it.
- 01Choose what proxy.ts runs forWrite a matcher, and tell which requests reach proxy.ts: pages, prefetches, files in public and Next.js's own files.
- 02Answer before the route doesLet a request through, rewrite it, redirect it, add headers, and read and set cookies, and see each one in the response.
- 03Keep proxy.ts in its placeMove from middleware.ts, use the Node.js runtime, and keep slow work and the only access check out of proxy.ts.
A checkpoint in front of the routes
The tea shop wants to try a new menu. Some visitors should see the new one at /teas, and everyone else the old one, at the same address. The page for /teas could read the cookie itself, but then it would be rendered again for every request. To keep both menus made during the build, the choice has to happen for each request, before Next.js picks the page. That's what proxy.ts is for.
proxy.ts is one file at the root of the project, beside the app folder (or inside src, if the project has one). It exports a function that Next.js runs on the server for each request it matches, before the request reaches a route. Redirects in Next.js took a first look at it. Before Next.js 16 the same file was called middleware.ts. Here is where it sits in the shop:
app/
new-menu/
page.tsx
teas/
page.tsx
layout.tsx
page.tsx
proxy.tsAnd here is the shop with it, built with next build and run with next start from Next.js 16.4.3. What the server sent back was recorded.
proxy.tsimport { NextResponse, type NextRequest } from "next/server"; export function proxy(request: NextRequest) { if (request.cookies.get("menu")?.value === "new") { return NextResponse.rewrite(new URL("/new-menu", request.url)); } return NextResponse.next();} export const config = { matcher: "/teas" };Green tea: $4
Mint tea: $3
Sent with cookie: menu=new. The page as the browser shows it when its HTML arrives, drawn with plain styles. Its JavaScript doesn't run here.
Line 3 exports a function named proxy. Next.js calls it with the request, a NextRequest: the browser's request, with helpers like request.cookies. Line 4 reads the menu cookie. When it says new, line 5 returns NextResponse.rewrite with the address of /new-menu. Otherwise line 7 returns NextResponse.next(): carry on to the route the address names. Line 10 is the matcher: Next.js runs proxy only for /teas.
The Browser tab opens on /teas, in the new-menu test: the request carried the cookie menu=new, and the page says Teas for your mood. Pick plain /teas above the page: no cookie, and the old menu, Our teas. Same address, two pages. Then open the Terminal tab. Both /teas and /new-menu are ○, prerendered as static content, and a new line sits under the routes: ƒ Proxy (Middleware). The choice between the menus happens in proxy.ts, so both pages could still be made during the build.
proxy.ts before any route. It lets the request go on, rewrites it to another route at the same address, sends a redirect back, or answers itself. In the overview, menu=new leads to the rewrite.Step through the two requests to see who does what, and when the visitor sees something:
No menu cookieafter step 1A blank page
React isn't running yet: clicks do nothing.
The page isn't complete yet.
The browser asks the shop for /teas. This visitor has no menu cookie, so the request carries none.
In both journeys, nothing reaches the browser until step 4. proxy.ts runs before Next.js sends a single byte, which is why it can still pick another page, or send a redirect with a real 307 status.
A hotel's front desk sees every guest first. It reads the booking card, sends one guest to room 12 and another to the hotel across the street, and hands out key cards. It works fast, because a queue is forming. It doesn't cook or clean the rooms. And each room still has a lock of its own, because some guests come in through the garage.
- In real life: A guest walks in and shows a booking card
- In Next.js: A request arrives with its cookies and headers
- In real life: The desk reads the card before anyone gets a room
- In Next.js: proxy.ts reads the request before any route runs
- In real life: "Your room today is 12", in the same hotel
- In Next.js:
NextResponse.rewrite: another route, same address - In real life: "This booking is for our other hotel, go there"
- In Next.js:
NextResponse.redirect: the browser asks for another address - In real life: Each room's door still takes a key card
- In Next.js: Each page, Route Handler and Server Action checks access too
proxy.ts is one function that runs on the server before the request reaches its route. It reads the request and lets it through, rewrites it to another route, redirects it, adds headers and cookies, or answers it itself. Its matcher decides which requests it sees. Keep it small and fast, and never make it the only access check.
Which requests reach proxy.ts: the matcher
Without a matcher, proxy.ts runs for every request the server gets. That's more than the pages. To see it, give the shop a proxy.ts that only writes down each path it sees, with console.log on line 4, and three versions of its config. For each one, the recorder asked for six addresses: three pages, the Route Handler /api/stock, and two files in public, /robots.txt and /leaf.svg. Then it opened / in Chrome. Pick a version and read the Terminal tab: after ✓ Ready comes what next start printed.
proxy.tsimport type { NextRequest } from "next/server"; export function proxy(request: NextRequest) { console.log("proxy.ts ran for", request.nextUrl.pathname);}$ next build▲ Next.js 16.4.3 (Turbopack)- Cache Components enabled- Partial Prefetching enabled Creating an optimized production build ...✓ Compiled successfully Running TypeScript ... Finished TypeScript ... Collecting page data ...✓ Generating static pages (7/7) Finalizing page optimization ... Route (app)┌ ○ /├ ○ /_not-found├ ○ /api/stock├ ○ /new-menu├ ○ /teas└ ○ /teas/mint ƒ Proxy (Middleware) ○ (Static) prerendered as static content $ next start▲ Next.js 16.4.3- Local: http://127.0.0.1:3000- Network: http://127.0.0.1:3000✓ Readyproxy.ts ran for /proxy.ts ran for /teasproxy.ts ran for /teas/mintproxy.ts ran for /api/stockproxy.ts ran for /robots.txtproxy.ts ran for /leaf.svgproxy.ts ran for /proxy.ts ran for /_next/static/chunks/1x6obpmlu1_c2.jsproxy.ts ran for /_next/static/chunks/06bs-nf19b2js.jsproxy.ts ran for /_next/static/chunks/2ki52vdnbo2ft.jsproxy.ts ran for /_next/static/chunks/3l_qmp-61z4yc.jsproxy.ts ran for /_next/static/chunks/turbopack-3-ry682tkyiqk.jsproxy.ts ran for /_next/static/chunks/3oh00mkbkq8u6.jsproxy.ts ran for /favicon.icoproxy.ts ran for /proxy.ts ran for /teasproxy.ts ran for /teasWith no config, proxy runs for every request: the pages, /api/stock, /robots.txt, /leaf.svg, and, when Chrome opens /, each JavaScript file under /_next/static, /favicon.ico and the router's prefetches.
With no matcher, the log has a line for everything:
- the six addresses, including
/api/stock,/robots.txtand/leaf.svg; - when Chrome opened
/: the page, each JavaScript file under/_next/static/chunks/, and/favicon.ico, which browsers ask for on their own; - and the router's prefetches: when the
HomeandTeaslinks came on screen, Next.js's router fetched parts of those pages ahead of a click (Linking and navigating shows them). Each one is a request, so each one ran proxy.ts.
A proxy.ts that redirects visitors without a session would then redirect the requests for the page's own JavaScript too, and the page would break. So Next.js's docs suggest a matcher that leaves Next.js's own files out. It's the second version:
import type { NextRequest } from "next/server"; export function proxy(request: NextRequest) { console.log("proxy.ts ran for", request.nextUrl.pathname);} export const config = { matcher: ["/((?!api|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)"],}; A matcher is a path pattern. A string inside round brackets is a regular expression, and (?!…) means "not followed by". So line 8 reads: any path, except one whose part after the first / starts with api, _next/static, _next/image, favicon.ico, sitemap.xml or robots.txt. The recording agrees: no line for the JavaScript files, /favicon.ico, /api/stock or /robots.txt. But there is a line for /leaf.svg. The pattern names only those few files, so any other file in public still runs proxy.ts. To leave out more files, add them inside the brackets, the way the docs' own example adds .*\\.png$ to skip every PNG image.
/leaf.svg reach proxy.ts. Next.js's JavaScript files, /api/stock, /robots.txt and /favicon.ico skip it, because the pattern names them.The third version names one path: matcher: "/teas". Its log has no line for /teas/mint. A plain path matches only itself. To cover the paths under it as well, add a named part with *, which means zero or more: /teas/:path* matches /teas, /teas/mint and /teas/mint/reviews. Its log has three lines for /teas: the recorder's own request, and two prefetches Chrome's router made for the Teas link. Prefetches go through the same matcher as everything else.
That isn't what the docs' list of matcher rules says for Next.js 16.4.3: one of its points says /about matches /about and /about/team. This build did otherwise, so check a matcher against the paths you care about, the way this log does.
The matcher has a few more rules, from Next.js's docs:
- It can be one string or an array of them:
["/teas", "/stock/:path*"]. - Each pattern starts with
/.:namematches one part of the path,:name*zero or more,:name+one or more, and:name?zero or one. - The values must be written out in the file. Next.js reads them during the build, so a matcher built from a variable is ignored.
- An entry can also be an object with a
sourcepattern andhasormissingconditions on headers, cookies or the query string, for example to skip the router's prefetches.
A Server Action isn't a route of its own. When a form calls one, the browser sends a POST to the page the form is on (Server Actions shows it). So a matcher that leaves a page out leaves out that page's actions too.
Rewrites, redirects and headers
Whatever proxy.ts returns decides what happens next. NextResponse, from next/server, is the Response class of the web platform with extra helpers for Next.js. Each row is one thing proxy can return:
| proxy returns | What the visitor gets | Status and address |
|---|---|---|
NextResponse.next(), or nothing | The route the address names, as if proxy.ts weren't there. | The status the route gives, at the same address. |
NextResponse.rewrite(url) | Another route's answer, while the address bar keeps the address the visitor asked for. | The status that route gives, at the same address. |
NextResponse.redirect(url) | No page: the browser is told to ask for another address. | 307 with a location header, unless you pass another status. |
A Response, like Response.json(…) | Your answer, and no route runs at all. | The status you give it. |
A rewrite answers with another route while the address bar keeps what the visitor typed: the overview's menu. A redirect sends the browser away: its answer is 307 with a location header, and the browser asks for that address next. Redirects in Next.js explains the status codes, and why a redirect that must be a real 307 belongs here, before the page starts. Both take a full address, so the shop builds it from the request's own with new URL("/new-menu", request.url).
Headers on the request, and on the response
proxy.ts can also add headers, in two directions. The shop wants a support code on its checkout page: an id that's new for every request, which the page shows and the server's logs can write down, so a customer can quote it.
proxy.tsimport { NextResponse, type NextRequest } from "next/server"; export function proxy(request: NextRequest) { const id = crypto.randomUUID(); const headers = new Headers(request.headers); headers.set("x-request-id", id); const response = NextResponse.next({ request: { headers } }); response.headers.set("x-request-id", id); return response;} export const config = { matcher: "/checkout" };GET /checkout200
- Sent with the request
cookiecart=mint
- Response headers
x-request-idf09f0af0-d1ee-4edd-92a3-7a9df816bd40Varyrsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encodingx-nextjs-stale-time300x-nextjs-prerender1x-nextjs-postponed1X-Powered-ByNext.jsCache-Controlprivate, no-cache, no-store, max-age=0, must-revalidateContent-Typetext/html; charset=utf-8
The HTML that arrived, one element per line, with the code of its 14 scripts left out
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="preload" as="script" fetchpriority="low" href="/_next/static/chunks/1x6obpmlu1_c2.js">
<script src="/_next/static/chunks/06bs-nf19b2js.js" async=""></script>
<script src="/_next/static/chunks/2ki52vdnbo2ft.js" async=""></script>
<script src="/_next/static/chunks/3l_qmp-61z4yc.js" async=""></script>
<script src="/_next/static/chunks/turbopack-3-ry682tkyiqk.js" async=""></script>
<script src="/_next/static/chunks/3oh00mkbkq8u6.js" async=""></script>
<title>Tea shop</title>
<script src="/_next/static/chunks/0cz1d0mv5g_q7.js" nomodule=""></script>
</head>
<body>
<div hidden="">
<!--$-->
<!--/$-->
</div>
<nav><a href="/">Home</a> <a href="/teas">Teas</a></nav>
<!--&-->
<!--&-->
<main>
<h1>Checkout</h1>
<!--$?-->
<template id="B:0"></template>
<p>Loading…</p>
<!--/$-->
</main>
<!--$-->
<!--/$-->
<!--/&-->
<!--/&-->
<script>…</script>
<script id="_R_">…</script>
<script src="/_next/static/chunks/1x6obpmlu1_c2.js" async=""></script>
<script>…</script>
<script>…</script>
<script src="/_next/static/chunks/0cz1d0mv5g_q7.js" nomodule=""></script>
<div hidden="" id="S:0">
<p>Support code: <!-- -->f09f0af0-d1ee-4edd-92a3-7a9df816bd40</p>
</div>
<script>…</script>
<script>…</script>
</body>
</html>Line 4 makes a random id with crypto.randomUUID(). Line 6 copies the request's headers into a new Headers object, and line 7 adds x-request-id to the copy. Line 8 passes the copy as { request: { headers } }: these become the headers the route sees. Line 10 sets the same header on the response, which goes to the browser. In app/checkout/page.tsx, line 5 reads it with headers(), from Request-time APIs, inside the Suspense on lines 13 to 15.
The Network tab shows the response's x-request-id, and the page shows the same id after Support code:. The request carried the cookie cart=mint, and no cookie header came back: only the headers proxy.ts set on purpose reach the browser.
NextResponse.next({ request: { headers } }) is the documented way to change the request the route sees. NextResponse.next({ headers }), without request, sets response headers: every header in the object goes back to the browser, which leaks anything in it that was meant only for your server. Next.js's docs warn about mixing them up. Next.js 16.4.3 also copies the headers proxy.ts sets on its response onto the request the page reads, so there a page can see them either way; that's how this release works (its source is linked at the end), not something the docs promise. Also, keep the headers small: a server can refuse a request whose headers are too large.
proxy.ts runs on Node.js
A runtime is the set of APIs your server code can use. Next.js has two. Node.js is the full server runtime, with modules like node:crypto and node:fs. The Edge runtime is a smaller one, with only the web platform's APIs, made to start fast on servers close to the visitor. For years, middleware.ts ran only on Edge. Node.js became an experimental option in Next.js 15.2 and a stable one in 15.5. Next.js 16 made it the rule: proxy.ts always runs on Node.js, and its runtime can't be changed.
So proxy.ts can use Node.js's modules, and npm packages that need them. The shop's staff page shows why that matters. When a staff member signs in, the shop gives them a cookie that holds their name and a signature: an HMAC, a code made from the name and a secret only the server knows. Anyone can write a cookie, but nobody without the secret can make a matching signature. (Route Handlers used the same check for a webhook.) Pick each version and read the Terminal tab:
proxy.tsimport { NextResponse, type NextRequest } from "next/server";import { readSession } from "./lib/session"; export function proxy(request: NextRequest) { if (!readSession(request.cookies.get("session")?.value)) { return NextResponse.redirect(new URL("/sign-in", request.url)); } return NextResponse.next();} export const config = { matcher: "/stock/:path*" };$ next build▲ Next.js 16.4.3 (Turbopack)- Environments: .env- Cache Components enabled- Partial Prefetching enabled Creating an optimized production build ...✓ Compiled successfully Running TypeScript ... Finished TypeScript ... Collecting page data ...✓ Generating static pages (7/7) Finalizing page optimization ... Route (app)┌ ○ /├ ○ /_not-found├ ƒ /api/stock├ ○ /sign-in├ ◐ /stock└ ○ /teas ƒ Proxy (Middleware) ○ (Static) prerendered as static content◐ (Partial Prerender) prerendered as static HTML with dynamic server-streamed contentƒ (Dynamic) server-rendered on demandproxy.ts runs on Node.js, so lib/session.ts can use node:crypto. The build passes, and /stock answers 307 without a valid session and 200 with Asha's.
The first version is the shop as it should be. In lib/session.ts, line 1 imports createHmac and timingSafeEqual from node:crypto. Line 6 accepts only a signature of 64 hexadecimal characters, the size of an HMAC-SHA256. Line 7 makes the signature the name should have, from SESSION_SECRET in .env, and line 9 compares the two with timingSafeEqual, which takes the same time however many characters match, so nobody can guess the signature by timing the answers. In proxy.ts, line 5 calls it, and line 6 redirects anyone without a valid session. The recorded answers: 307 to /sign-in without a cookie, 200 with Green tea: 12 boxes for Asha, and 307 again for a signature someone made up.
The second version is the same code in a middleware.ts, which still uses Edge in Next.js 16.4.3. The build passes, with a warning: A Node.js module is loaded ('node:crypto' at line 1) which is not supported in the Edge Runtime. Then every request to /stock answers 500, and next start prints Native module not found: node:crypto. The third version is a proxy.ts that asks for Edge on line 11. The build stops with an error that starts Next.js can't recognize the exported `config` field in route. and ends Proxy does not support Edge runtime.
Next.js's docs say proxy.ts is meant to run apart from your rendering code, and in some setups on servers of its own. So don't keep values in module-level variables to share them with your pages. Pass what the route needs through headers, cookies, a rewrite, a redirect or the address.
From middleware.ts to proxy.ts
Next.js 16.0.0, released on October 21, 2025, renamed the file. The docs give two reasons. "Middleware" made people think of Express.js middleware, which works differently. And the new name says what the file is: a network boundary in front of the app, something to use only when nothing else fits. The code inside stays the same. Each row compares the two names in Next.js 16.4.3:
| Question | proxy.ts | middleware.ts |
|---|---|---|
| The file | proxy.ts, beside the app folder (or in src). | middleware.ts, in the same place. |
| The function it exports | proxy, or a default export. | middleware, or a default export. |
| Which runtime it uses | Node.js, always. Asking for another one stops the build. | The Edge runtime, unless its config asks for Node.js. |
| What next build says | Nothing extra: ƒ Proxy (Middleware) under the routes. | A warning that the name is deprecated, with the codemod to run, then the same ƒ Proxy (Middleware) line. |
| Its future | The name to use from Next.js 16 on. | Deprecated in 16.0.0, and to be removed in a future version. |
Here is the overview's rewrite with the old names, built with Next.js 16.4.3. Open the Terminal tab:
middleware.tsimport { NextResponse, type NextRequest } from "next/server"; export function middleware(request: NextRequest) { if (request.cookies.get("menu")?.value === "new") { return NextResponse.rewrite(new URL("/new-menu", request.url)); } return NextResponse.next();} export const config = { matcher: "/teas" };$ next build▲ Next.js 16.4.3 (Turbopack)- Cache Components enabled- Partial Prefetching enabled ⚠ The "middleware" file convention is deprecated. Please use "proxy" instead. To migrate automatically, run: npx @next/codemod@canary middleware-to-proxy . Learn more: https://nextjs.org/docs/messages/middleware-to-proxy Creating an optimized production build ...✓ Compiled successfully Running TypeScript ... Finished TypeScript ... Collecting page data ...✓ Generating static pages (5/5) Finalizing page optimization ... Route (app)┌ ○ /├ ○ /_not-found├ ○ /new-menu└ ○ /teas ƒ Proxy (Middleware) ○ (Static) prerendered as static contentLine 3 exports middleware, and the file is middleware.ts. The build starts with The "middleware" file convention is deprecated. Please use "proxy" instead., then finishes, and the Browser tab shows Teas for your mood for menu=new, as before. To move, rename the file to proxy.ts and the function to proxy, or let the codemod the warning names do both:
npx @next/codemod@canary middleware-to-proxy .A codemod is a program that changes your code for you. This one renames the file and the function. Two things it can't decide for you. A project can't have both files: Next.js's build stops if it finds both. And a middleware.ts that relies on the Edge runtime, for example to run on a platform's edge servers, shouldn't be renamed blindly: the upgrade guide says to keep using middleware.ts for Edge for now. Settings with the old name were renamed too, like skipMiddlewareUrlNormalize, now skipProxyUrlNormalize.
What not to do in proxy.ts
proxy.ts runs for every request its matcher lets in, and before anything else. That makes two mistakes expensive.
Slow work
Every page load, every prefetch and every client-side navigation that matches waits for proxy.ts first. Next.js's docs say it's not meant for slow data fetching, and that fetch's cache options, like next.revalidate or next.tags, have no effect there. A database query in proxy.ts runs again for every one of those requests. Read what the request already carries (its address, its cookies, its headers) and decide. Load data in your pages and components, where you can cache it. For work the visitor shouldn't wait for, like sending a log line, the function's second argument has event.waitUntil(promise), which lets the work finish after the response has gone.
Making it the only access check
The session check in proxy.ts is useful: visitors without a session go to /sign-in with a real 307, before anything renders. The docs call it an optimistic check: a quick look at the cookie, not the real decision. The real decision belongs next to the data. In the shop, that's getStock() in lib/stock.ts: the only function the rest of the app reads the stock through, and it checks the session again. A file like it, with every read of the database and the checks that guard it, is called a data access layer. Here's why it's needed. The shop also has /api/stock, which proxy.ts's matcher, /stock/:path*, doesn't cover:
app/api/stock/route.tsimport { allStock } from "../../../lib/db"; // proxy.ts already checked the session. (It didn't: its matcher is /stock/:path*.)export async function GET() { return Response.json(await allStock());}GET /api/stock200
- Response headers
varyrsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetchx-nextjs-cacheHITcache-controls-maxage=31536000content-typeapplication/json
The body that arrived, as it arrived
[{"tea":"Green tea","boxes":12},{"tea":"Mint tea","boxes":4}]The matcher on line 11 of proxy.ts is /stock/:path*, so /api/stock never meets proxy. This handler reads the database directly: anyone gets the stock, and the build even prerendered it as ○ /api/stock.
The first handler trusts proxy.ts, and line 5 returns the database's rows to anyone: the recorded answer is 200 with both teas and their boxes, without a cookie. The build even prerendered it as ○ /api/stock, since it reads nothing from the request. The second handler calls getStock() on line 4 and answers 401 with {"error":"Sign in first"}. Nothing about proxy.ts changed between the two.
proxy.ts stands at one door: its matcher is /stock/:path*. /api/stock never meets it. Both doors lead to getStock(), which checks the session next to the data, so neither door can skip that check.Matchers get edited, routes move, and a Server Action is a POST to whichever page uses it. The docs say a refactor can silently take a route out of proxy.ts's reach, and that each Server Action and Route Handler must check who is asking. This course's Authorization and the data access layer lesson builds a full data access layer.
In March 2025, a flaw named CVE-2025-29927 let a request skip middleware.ts entirely, by sending one header that Next.js used internally. Apps whose only check was in middleware were open. The advisory covers self-hosted apps on Next.js 11.x up to 15.2.2: it was fixed in 12.3.5, 13.5.9, 14.2.25 and 15.2.3, with a workaround for 11.x, and the advisory says deployments hosted on Vercel were protected automatically. The case study The middleware bypass, later in the course, goes through it. In apps that also checked next to their data, those checks still ran.
One proxy.ts for the whole shop
A project has one proxy.ts. When it has several jobs, keep each job in a module of its own and let proxy.ts send each request to the right one. Here are the menu test and the staff check together:
proxy.tsimport type { NextRequest } from "next/server";import { menuTest } from "./lib/menu-test";import { staffOnly } from "./lib/staff-only"; export function proxy(request: NextRequest) { if (request.nextUrl.pathname.startsWith("/stock")) { return staffOnly(request); } return menuTest(request);} export const config = { matcher: ["/teas", "/stock/:path*"] };GET /stock307
- Response headers
location/sign-in
No HTML arrived: the body is only the new address, as plain text
/sign-inLine 12 lists both paths in one matcher. Lines 6 to 8 send /stock and everything under it to staffOnly, from lib/staff-only.ts, and line 9 sends the rest, which can only be /teas, to menuTest, from lib/menu-test.ts. The recorded answers are the ones from before: the new menu for menu=new, 307 to /sign-in without a session, and the stock for Asha.
Not everything that runs before a page belongs in proxy.ts. Sort each job:
- Show half of the new visitors the new menu at /teas.
- /menu moved to /teas for good.
- Only staff may read the stock numbers.
- Give every checkout request an id the page and the logs share.
- Read today's prices from the database.
- Send visitors without a session cookie to /sign-in before /stock renders.
- Send the same X-Frame-Options header with every page.
- Check that the person posting an order may post it.
Sort each job: proxy.ts, next.config.ts, or the route's own code (a page, a Route Handler, a Server Action, or the data functions they call).
Common mix-ups
- "proxy.ts only runs for pages." No. Without a matcher it runs for every request: JavaScript files, files in
public, Route Handlers and prefetches. The recording logged all of them. - "The docs' matcher skips every static file." No. It skips the paths it names.
/leaf.svgstill ran proxy.ts. - "
/teasin the matcher covers/teas/mint." No. A plain path matches only itself. Use/teas/:path*. - "A rewrite sends the visitor to another address." No. That's a redirect. A rewrite answers with another route at the same address.
- "
NextResponse.next({ headers })is how you pass headers to the page." No. It sets response headers, sent to the browser. In 16.4.3 the page could read them too, but the docs don't promise that;{ request: { headers } }is the supported way. - "proxy.ts runs on the Edge runtime." No. In Next.js 16 it always runs on Node.js. Only the deprecated
middleware.tsstill uses Edge unless it asks otherwise. - "A check in proxy.ts protects everything behind it." No. A matcher can miss a route or an action, and a flaw can skip the file. Check again next to the data.
Practice exercises
These exercises are about Next.js projects, which can't run inside this page. Answer first, then use the hints.
The shop's proxy.ts uses the matcher from Next.js's docs. Which of these requests run it?
GET /teas
GET /_next/static/chunks/1x6obpmlu1_c2.js
GET /api/stock
GET /robots.txt
GET /leaf.svg/teas and /leaf.svg. The pattern names _next/static, api and robots.txt, so those three skip proxy.ts. Nothing names leaf.svg, so it still runs it, as the recorded log shows.
Someone sends /stock the cookie session=Asha. followed by 64 zeros. What does the shop answer?
307, with location: /sign-in. The signature has the right format, but timingSafeEqual finds it isn't the HMAC of Asha with the shop's secret, so readSession returns null and proxy.ts redirects. The recording shows exactly that.
This proxy.ts doesn't run for /teas/mint. Change line 7 so it runs for /teas and every page under it.
import type { NextRequest } from "next/server";
export function proxy(request: NextRequest) {
console.log("proxy.ts ran for", request.nextUrl.pathname);
}
export const config = { matcher: "/teas" };
import type { NextRequest } from "next/server";
export function proxy(request: NextRequest) {
console.log("proxy.ts ran for", request.nextUrl.pathname);
}
export const config = { matcher: "/teas/:path*" };
/teas/:path*. * means zero or more, so it matches /teas itself and every path under it. The recorded log of this version has a line for /teas and one for /teas/mint.
A teammate shortened line 8. The page still shows a support code, but the response now carries one header it shouldn't. The request had the cookie cart=mint. Which header came back?
import { NextResponse, type NextRequest } from "next/server";
export function proxy(request: NextRequest) {
const id = crypto.randomUUID();
const headers = new Headers(request.headers);
headers.set("x-request-id", id);
const response = NextResponse.next({ headers });
response.headers.set("x-request-id", id);
return response;
}
export const config = { matcher: "/checkout" };
import { NextResponse, type NextRequest } from "next/server";
export function proxy(request: NextRequest) {
const id = crypto.randomUUID();
const headers = new Headers(request.headers);
headers.set("x-request-id", id);
const response = NextResponse.next({ request: { headers } });
response.headers.set("x-request-id", id);
return response;
}
export const config = { matcher: "/checkout" };
cookie. Line 8 passes a copy of every request header as response headers, so the recorded response carries cookie: cart=mint, the visitor's own cookie. The page still showed a code because Next.js 16.4.3 also copies response headers onto the request, which the docs don't promise. With { request: { headers } }, the documented way, the copy goes to the route, and the response has only x-request-id.
This is a project's middleware.ts. Its build prints a deprecation warning. What do you rename to stop it?
import { NextResponse, type NextRequest } from "next/server";
export function middleware(request: NextRequest) {
if (request.cookies.get("menu")?.value === "new") {
return NextResponse.rewrite(new URL("/new-menu", request.url));
}
return NextResponse.next();
}
export const config = { matcher: "/teas" };
import { NextResponse, type NextRequest } from "next/server";
export function proxy(request: NextRequest) {
if (request.cookies.get("menu")?.value === "new") {
return NextResponse.rewrite(new URL("/new-menu", request.url));
}
return NextResponse.next();
}
export const config = { matcher: "/teas" };
Rename the file to proxy.ts and the function to proxy, by hand or with npx @next/codemod@canary middleware-to-proxy .. The recorded build of the renamed file prints no warning, and /teas with menu=new still shows the new menu. This file uses no Edge-only setup, so it can move.
This Route Handler at /api/stock gives the stock to anyone. What should it call instead of allStock()?
import { allStock } from "../../../lib/db";
// proxy.ts already checked the session. (It didn't: its matcher is /stock/:path*.)
export async function GET() {
return Response.json(await allStock());
}
import { getStock } from "../../../lib/stock";
export async function GET() {
const stock = await getStock();
if (!stock) return Response.json({ error: "Sign in first" }, { status: 401 });
return Response.json(stock);
}
getStock(). It reads the session cookie and returns null without a valid one, so the handler can answer 401. The recording of this version answers 401 with Sign in first, while proxy.ts and its matcher are unchanged.
Check your understanding
For each question, ask which requests reach proxy.ts, and what it returns for them.
Question 1 of 7In this proxy.ts, what does a visitor with the cookie
menu=newget when they open/teas?Read the code, then predictTypeScriptimport { NextResponse, type NextRequest } from "next/server"; export function proxy(request: NextRequest) { if (request.cookies.get("menu")?.value === "new") { return NextResponse.rewrite(new URL("/new-menu", request.url)); } return NextResponse.next(); } export const config = { matcher: "/teas" };Choose an answer to see the explanation.
Question 2 of 7A proxy.ts has no
configat all. Which requests does it run for?Choose an answer to see the explanation.
Question 3 of 7This proxy.ts logs each path it sees. Does it run for
/teas/mint?Read the code, then predictTypeScriptimport type { NextRequest } from "next/server"; export function proxy(request: NextRequest) { console.log("proxy.ts ran for", request.nextUrl.pathname); } export const config = { matcher: "/teas" };Choose an answer to see the explanation.
Question 4 of 7Which runtime does proxy.ts use in Next.js 16?
Choose an answer to see the explanation.
Question 5 of 7What happens to a
middleware.tsfile in Next.js 16.4.3?Choose an answer to see the explanation.
Question 6 of 7This proxy.ts adds an id to the request. What else reaches the browser in the response?
Read the code, then predictTypeScriptimport { NextResponse, type NextRequest } from "next/server"; export function proxy(request: NextRequest) { const id = crypto.randomUUID(); const headers = new Headers(request.headers); headers.set("x-request-id", id); const response = NextResponse.next({ headers }); response.headers.set("x-request-id", id); return response; } export const config = { matcher: "/checkout" };Choose an answer to see the explanation.
Question 7 of 7proxy.ts already sends visitors without a session away from
/stock/:path*. Why doesgetStock()check the session again?Choose an answer to see the explanation.
Key takeaways
proxy.ts, besideapp, exports one function that runs on the server before a request reaches its route. Before Next.js 16 it wasmiddleware.ts.- Without a matcher it runs for every request, Next.js's own files included. The docs' matcher skips the paths it names; a plain path matches only itself, and
:path*covers what's under it. - It returns
next()to carry on,rewrite()for another route at the same address,redirect()for a307, or aResponseof its own. - Headers for the route go in
{ request: { headers } }, the documented way; response headers go to the browser, even though 16.4.3 also copies them onto the request.request.cookiesreads cookies, andresponse.cookies.setsendsset-cookie. - proxy.ts always runs on Node.js.
middleware.tsstill works in 16.4.3, with a deprecation warning, on Edge unless it asks otherwise. - Keep it fast, and never the only access check: check again next to the data, in every Route Handler and Server Action.
Remember the one-liner.
proxy.ts decides where a request goes, quickly and before any route; the routes still decide who may see their data.
The deprecation warning comes from lines 1524 to 1540 of build/index.ts, which also stops the build when both files exist. runDependingOnPageType, at lines 339 to 352 of build/entries.ts, sends proxy.ts to the Node.js server always, and middleware.ts to Edge unless it asks for Node.js. And lines 711 to 736 of resolve-routes.ts copy the headers proxy.ts sets onto the response, and in this version onto the request too. That last one is how this release works, not a documented promise: pass headers for the route with { request: { headers } }.
Coming next: Internationalization. It uses proxy.ts to choose each visitor's language from their browser's settings, and serves the shop in English and French.