cf.completefrontend
Open lab My progress
THE NEXT.JS FIELD GUIDE

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.

By the end, you can
  • 01
    Choose 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.
  • 02
    Answer 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.
  • 03
    Keep 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:

The tea shop's files, with proxy.ts at the rootText
app/
  new-menu/
    page.tsx
  teas/
    page.tsx
  layout.tsx
  page.tsx
proxy.ts

And 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.ts shows the new menu to visitors in the testNext.js
app/new-menu/teas/
proxy.ts
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" };
Recorded from Next.js 16.4.3
localhost:3000/teasTea shop
Home
Teas for your mood
To wake up

Green tea: $4

To calm down

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.

A browser window asks for GET /teas with the cookie menu=new. An arrow goes to a card for proxy.ts. From proxy.ts, four arrows go to four answers: next(), which goes on to app/teas, the route the address names; rewrite, to app/new-menu, another route at the same address; redirect, a 307 that makes the browser ask again; and Response.json, where proxy.ts answers itself.browserGET /teasmenu=newproxy.tsnext(): app/teasthe route the address namesrewrite: app/new-menuanother route, same addressredirect: 307the browser asks againResponse.json(…)proxy.ts answers itself
Every request the matcher lets in meets 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:

Playground: one address, two menus
Who does whatNo menu cookie
What the visitor seesafter step 1
tea-shop.example/teas

A blank page

React isn't running yet: clicks do nothing.

The page isn't complete yet.

Step 1 of 4
Browser: The visitor opens /teas

The browser asks the shop for /teas. This visitor has no menu cookie, so the request carries none.

Each step is one thing that happens, in order, for the overview's proxy.ts. The screens are the recorded answers for /teas without a cookie and with menu=new. Reset goes back to the first journey's first step.

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.

Real-life analogyThe front desk of a hotel

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
The main idea

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.

Playground: which requests reach proxy.ts?
The tea shop with a proxy.ts that logs each path it seesNext.js
app/api/stock/new-menu/teas/mint/public/
proxy.ts
import type { NextRequest } from "next/server"; export function proxy(request: NextRequest) {  console.log("proxy.ts ran for", request.nextUrl.pathname);}
Recorded from Next.js 16.4.3
$ 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 /teas
Try it yourself
No matcher

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

Every version was built with next build and run with next start from Next.js 16.4.3. The Terminal shows what next start printed while the recorder asked for six addresses and then opened / in Chrome. Only proxy.ts changes. Reset goes back to the first version.

With no matcher, the log has a line for everything:

  • the six addresses, including /api/stock, /robots.txt and /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 Home and Teas links 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:

The matcher Next.js's docs suggestTypeScript
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.

Requests from one visit, sorted by the matcher from Next.js's docs. On the left, three requests reach proxy.ts: slash, the prefetch of slash teas, and slash leaf.svg, with arrows into a card for proxy.ts. On the right, four requests skip it, drawn faded: a JavaScript file under slash _next slash static, slash api slash stock, slash robots.txt and slash favicon.ico.reach proxy.ts//teas (prefetch)/leaf.svgproxy.tsruns for each of theseskip it/_next/static/…js/api/stock/robots.txt/favicon.iconamed in the pattern
The recording with the docs' matcher. Pages, the router's prefetches and /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 /. :name matches 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 source pattern and has or missing conditions on headers, cookies or the query string, for example to skip the router's prefetches.
Server Actions are POSTs to their page

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:

What proxy can return
proxy returnsWhat the visitor getsStatus and address
NextResponse.next(), or nothingThe 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.ts gives each checkout request an idNext.js
app/checkout/new-menu/teas/
proxy.ts
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" };
Recorded from Next.js 16.4.3

GET /checkout200

Sent with the request
cookie cart=mint
Response headers
x-request-id f09f0af0-d1ee-4edd-92a3-7a9df816bd40
Vary rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encoding
x-nextjs-stale-time 300
x-nextjs-prerender 1
x-nextjs-postponed 1
X-Powered-By Next.js
Cache-Control private, no-cache, no-store, max-age=0, must-revalidate
Content-Type text/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.

request.headers or headers

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.

Reading and setting cookies

A cookie is a header too: the browser sends its cookies in the request's cookie header, and a server sets one with a set-cookie header in its response. Next.js gives both sides helpers:

  • request.cookies reads what came in: get(name) gives { name, value } or undefined, has(name) says whether it's there, and getAll() lists them.
  • response.cookies sets what goes out: set(name, value, options) and delete(name). Each set becomes a set-cookie header on the response.

The overview's test needs a way to put visitors into it. Here, a visitor without a menu cookie gets one of the two menus at random, and a cookie that keeps them there for 30 days:

proxy.ts puts each new visitor in a group, and remembers itNext.js
app/new-menu/teas/
proxy.ts
import { NextResponse, type NextRequest } from "next/server"; export function proxy(request: NextRequest) {  const saved = request.cookies.get("menu")?.value;  const menu = saved === "new" || saved === "old" ? saved : Math.random() < 0.5 ? "new" : "old";   const response =    menu === "new" ? NextResponse.rewrite(new URL("/new-menu", request.url)) : NextResponse.next();   if (menu !== saved) {    response.cookies.set("menu", menu, { maxAge: 60 * 60 * 24 * 30 });  }  return response;} export const config = { matcher: "/teas" };
Recorded from Next.js 16.4.3

GET /teas200

Response headers
set-cookie menu=old; Path=/; Expires=Sun, 29 Nov 2026 03:32:47 GMT; Max-Age=2592000
Vary rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encoding
x-nextjs-cache HIT
x-nextjs-prerender 1
x-nextjs-stale-time 300
X-Powered-By Next.js
Cache-Control s-maxage=31536000
Content-Type text/html; charset=utf-8

The HTML that arrived, one element per line, with the code of its 15 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>Our teas</h1>
      <ul>
        <li>Green tea: $4</li>
        <li>Mint tea: $3</li>
      </ul>
    </main>
    <!--$-->
    <!--/$-->
    <!--/&-->
    <!--/&-->
    <script id="_R_">…</script>
    <script src="/_next/static/chunks/1x6obpmlu1_c2.js" async=""></script>
    <script>…</script>
    <script>…</script>
    <script>…</script>
    <script>…</script>
    <script>…</script>
    <script>…</script>
    <script>…</script>
  </body>
</html>

Line 4 reads the saved group. Line 5 keeps it when it's new or old, and picks one at random otherwise, so a made-up value doesn't count. Lines 7 and 8 choose the answer: a rewrite to /new-menu, or next(). Lines 10 to 12 set the cookie only when the group is new: maxAge is in seconds, so 60 * 60 * 24 * 30 is 30 days.

In the Network tab, the first visit's answer carries set-cookie with the group it picked, Path=/ and Max-Age=2592000, and the page is that group's menu. The answers for menu=old and menu=new carry no set-cookie: those visitors already had theirs. Each visitor keeps their group for 30 days, unless they delete their cookies.

Both menus are cached pages

Look at the headers of /teas with menu=new: x-nextjs-cache: HIT and Cache-Control: s-maxage=31536000, the same as /new-menu itself. Next.js sent the page it prerendered, so the test costs no rendering. proxy.ts runs before Next.js picks the page, so next start sends each visitor the right one. A shared cache that you put in front of the server, like a CDN, only sees one address whose answer may be kept for a year, and nothing that says the answer depends on a cookie.

Marking the rewritten answer private from proxy.ts doesn't fix that in this version. Here line 6 sets Cache-Control: private, no-store on the rewrite:

proxy.ts asks for a private answer, and Next.js sends the page's own headerNext.js
app/new-menu/teas/
proxy.ts
import { NextResponse, type NextRequest } from "next/server"; export function proxy(request: NextRequest) {  if (request.cookies.get("menu")?.value === "new") {    const response = NextResponse.rewrite(new URL("/new-menu", request.url));    response.headers.set("cache-control", "private, no-store");    return response;  }  return NextResponse.next();} export const config = { matcher: "/teas" };
Recorded from Next.js 16.4.3

GET /teas200

Sent with the request
cookie menu=new
Response headers
cache-control s-maxage=31536000
Vary rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encoding
x-nextjs-cache HIT
x-nextjs-prerender 1, 1
x-nextjs-stale-time 300
x-powered-by Next.js
content-type text/html; charset=utf-8

The HTML that arrived, one element per line, with the code of its 15 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>Teas for your mood</h1>
      <h2>To wake up</h2>
      <p>Green tea: $4</p>
      <h2>To calm down</h2>
      <p>Mint tea: $3</p>
    </main>
    <!--$-->
    <!--/$-->
    <!--/&-->
    <!--/&-->
    <script id="_R_">…</script>
    <script src="/_next/static/chunks/1x6obpmlu1_c2.js" async=""></script>
    <script>…</script>
    <script>…</script>
    <script>…</script>
    <script>…</script>
    <script>…</script>
    <script>…</script>
    <script>…</script>
  </body>
</html>

The recorded answer still says cache-control: s-maxage=31536000: Next.js 16.4.3 sent the prerendered page's own header. So the fix belongs in the shared cache. Set it to skip its cache for requests that carry the menu cookie, or for /teas while the test runs. Or have it keep one copy per value of that cookie, if it can: a Vary: Cookie header asks for that, but only works with a cache that honours it, and stores a separate copy for every different cookie header, which can be one per visitor.

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:

Playground: the same check, three files
The shop's stock page, guarded by a session check that uses node:cryptoNext.js
app/api/stock/sign-in/stock/teas/lib/
proxy.ts
import { 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*" };
Recorded from Next.js 16.4.3
$ 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 demand
Try it yourself
proxy.ts

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

Every version was built with next build from Next.js 16.4.3; the Terminal shows what it printed, and for middleware.ts what next start printed when /stock was asked for. lib/session.ts is the same in all three. Reset goes back to the first version.

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.

One thing Node.js doesn't change

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:

middleware.ts and proxy.ts
Questionproxy.tsmiddleware.ts
The fileproxy.ts, beside the app folder (or in src).middleware.ts, in the same place.
The function it exportsproxy, or a default export.middleware, or a default export.
Which runtime it usesNode.js, always. Asking for another one stops the build.The Edge runtime, unless its config asks for Node.js.
What next build saysNothing 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 futureThe 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:

The overview's proxy, as a middleware.tsNext.js
app/new-menu/teas/
middleware.ts
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" };
Recorded from Next.js 16.4.3
$ 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 content

Line 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:

The codemod from the warningShell
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:

Playground: a door proxy.ts doesn't guard
The shop's stock API, at /api/stockNext.js
app/api/stock/sign-in/stock/teas/lib/
app/api/stock/route.ts
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());}
Recorded from Next.js 16.4.3

GET /api/stock200

Response headers
vary rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch
x-nextjs-cache HIT
cache-control s-maxage=31536000
content-type application/json

The body that arrived, as it arrived

[{"tea":"Green tea","boxes":12},{"tea":"Mint tea","boxes":4}]
Try it yourself
Trusts proxy.ts

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.

Both versions were built and run with Next.js 16.4.3, and asked for /api/stock without a session cookie. proxy.ts, lib/session.ts and lib/stock.ts are the same in both; only app/api/stock/route.ts changes. Reset goes back to the first version.

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.

Two requests for the stock. GET slash stock passes proxy.ts first, whose matcher is slash stock colon path star, then reaches the page. GET slash api slash stock doesn't match, so it goes straight to its Route Handler. Both the page and the Route Handler call getStock, which checks the session cookie, and only then calls allStock, the database, below it.GET /stockproxy.tsthe pagechecks the cookieGET /api/stockroute.tsno match: no proxy.tsgetStock()allStock()the database
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.

When the check itself was skipped

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:

One proxy.ts, two jobsNext.js
app/api/stock/new-menu/sign-in/stock/teas/lib/
proxy.ts
import 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*"] };
Recorded from Next.js 16.4.3

GET /stock307

Response headers
location /sign-in

No HTML arrived: the body is only the new address, as plain text

/sign-in

Line 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:

Where does this job belong?
  • 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.
Try it yourself
0 of 8 correct

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

Choose a category for every card. You can change an answer at any time; Reset clears them all.

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.svg still ran proxy.ts.
  • "/teas in 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.ts still 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.

Exercise 1 · Warm-upSort one visit's requests

The shop's proxy.ts uses the matcher from Next.js's docs. Which of these requests run it?

Starter codeText
GET /teas
GET /_next/static/chunks/1x6obpmlu1_c2.js
GET /api/stock
GET /robots.txt
GET /leaf.svg

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upA signature someone made up

    Someone sends /stock the cookie session=Asha. followed by 64 zeros. What does the shop answer?

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeCover the pages under /teas

      This proxy.ts doesn't run for /teas/mint. Change line 7 so it runs for /teas and every page under it.

      Starter codeTypeScript
      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" };
      

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeFind the header that leaked

        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?

        Starter codeTypeScript
        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" };
        

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeMove a middleware.ts to Next.js 16

          This is a project's middleware.ts. Its build prints a deprecation warning. What do you rename to stop it?

          Starter codeTypeScript
          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" };
          

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeClose the door proxy.ts doesn't guard

            This Route Handler at /api/stock gives the stock to anyone. What should it call instead of allStock()?

            Starter codeTypeScript
            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());
            }
            

            Answer, then press Check. Spacing and letter case don’t matter.

              Check your understanding

              For each question, ask which requests reach proxy.ts, and what it returns for them.

              The proxy.ts quiz · 7 questionsScore: first tries count
              1. Question 1 of 7In this proxy.ts, what does a visitor with the cookie menu=new get when they open /teas?

                Read the code, then predictTypeScript
                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" };
                

                Choose an answer to see the explanation.

              2. Question 2 of 7A proxy.ts has no config at all. Which requests does it run for?

                Choose an answer to see the explanation.

              3. Question 3 of 7This proxy.ts logs each path it sees. Does it run for /teas/mint?

                Read the code, then predictTypeScript
                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" };
                

                Choose an answer to see the explanation.

              4. Question 4 of 7Which runtime does proxy.ts use in Next.js 16?

                Choose an answer to see the explanation.

              5. Question 5 of 7What happens to a middleware.ts file in Next.js 16.4.3?

                Choose an answer to see the explanation.

              6. Question 6 of 7This proxy.ts adds an id to the request. What else reaches the browser in the response?

                Read the code, then predictTypeScript
                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" };
                

                Choose an answer to see the explanation.

              7. Question 7 of 7proxy.ts already sends visitors without a session away from /stock/:path*. Why does getStock() check the session again?

                Choose an answer to see the explanation.

              Key takeaways

              • proxy.ts, beside app, exports one function that runs on the server before a request reaches its route. Before Next.js 16 it was middleware.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 a 307, or a Response of 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.cookies reads cookies, and response.cookies.set sends set-cookie.
              • proxy.ts always runs on Node.js. middleware.ts still 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.

              Read it in Next.js's own code

              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.

              CompleteFrontend Clear concepts. Working examples.