Skip to main content
Next.js · lesson 9 of 11
Next.js intermediate Lesson 9 of 11

proxy.ts: Redirects and Rewrites

Next.js 16 renamed middleware.ts to proxy.ts. A proxy redirects, rewrites, limits itself with matcher, and reads/sets a cookie — curl proves each one.

By SkillByExample · Updated

nextjs proxy middleware redirects rewrites

Next.js 16 renamed middleware.ts to proxy.ts — same job (code that runs before a request reaches a route), new name, chosen because “middleware” was easy to confuse with Express-style middleware. The project setup reuses the Docker pattern from the first lesson — the node:26.13 image, the nextjs-modules volume, MSYS_NO_PATHCONV=1 on Git Bash — see it for why each flag is there.

proxy.ts Is middleware.ts, Renamed

Nine files set up the project: package.json, next.config.ts, tsconfig.json, and app/layout.tsx are unchanged from the first lesson; app/page.tsx, app/old-page/page.tsx, app/secret/page.tsx, app/rewritten/page.tsx, and proxy.ts are new.

{
  "name": "next-app",
  "version": "1.0.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start"
  },
  "dependencies": {
    "next": "16.4.3",
    "react": "19.3.0",
    "react-dom": "19.3.0"
  },
  "devDependencies": {
    "typescript": "5.9.3",
    "@types/node": "20.19.43",
    "@types/react": "19.3.0",
    "@types/react-dom": "19.3.0"
  }
}
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  cacheComponents: true,
  partialPrefetching: true,
};

export default nextConfig;
{
  "compilerOptions": {
    "target": "ES2017",
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "bundler",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "react-jsx",
    "incremental": true,
    "plugins": [{ "name": "next" }],
    "paths": { "@/*": ["./*"] }
  },
  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts"],
  "exclude": ["node_modules"]
}
// app/layout.tsx — required; every page in the app renders inside this
export const metadata = {
  title: "Next App",
};

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  );
}
// app/page.tsx
export default function HomePage() {
  return <div>home page content</div>;
}
// app/old-page/page.tsx
export default function OldPage() {
  return <div>old page content</div>;
}
// app/secret/page.tsx
export default function SecretPage() {
  return <div>secret page content</div>;
}
// app/rewritten/page.tsx
export default function RewrittenPage() {
  return <div>rewritten page content</div>;
}

proxy.ts at the project root is the file convention itself — no import, no registration anywhere else. This first version does nothing but log which path it ran for and let every request through unchanged:

import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

export function proxy(request: NextRequest) {
  console.log("proxy ran for", request.nextUrl.pathname);
  return NextResponse.next();
}

Predict what the build output calls this file before building — is it still named after middleware, now that the file itself is renamed?

$ docker run --rm -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npm install --no-audit --no-fund

added 28 packages in 24s
$ docker run --rm -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx next build 2>&1 | grep -v -E "Generating static pages using|Collecting page data using|Finished filesystem cache database compaction"
▲ Next.js 16.4.3 (Turbopack)
✓ Running next.config.ts took 66ms
- Cache Components enabled
- Partial Prefetching enabled

  Creating an optimized production build ...
✓ Compiled successfully in 5.1s
  Running TypeScript ...
  Finished TypeScript in 2.9s ...
  Finalizing page optimization ...

Route (app)
┌ ○ /
├ ○ /_not-found
├ ○ /old-page
├ ○ /rewritten
└ ○ /secret


ƒ Proxy (Middleware)

○  (Static)  prerendered as static content

ƒ Proxy (Middleware) — the build output itself keeps the old name in parentheses, so a reader who learned “middleware” under Next.js 15 still recognizes this line. proxy.ts has no row of its own in the Route (app) table above it, because it isn’t a route; it runs in front of every one of them. Start the server and request two different paths — with no matcher written yet, the proxy has nothing telling it to skip anything:

$ docker run -d --name nextjs-proxy-1 -p 3100:3000 -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 node:26.13 npx next start
0392fae0bdefed2d51754fc231f2963f3be42bff2fcbaeaa997ca2dbc94d3c91
$ until curl -s -o /dev/null http://localhost:3100/; do sleep 0.3; done
$ curl -s -o /dev/null http://localhost:3100/secret
$ docker logs nextjs-proxy-1 2>&1 | grep 'proxy ran for'
proxy ran for /
proxy ran for /secret
$ docker rm -f nextjs-proxy-1
nextjs-proxy-1

proxy ran for / shows up even though that request only came from the readiness-wait loop checking the server was up — with no matcher, every single request runs the function, home page included.

A Redirect Returns 307 with a Location Header

Add a rule: a request for /old-page gets redirected to / instead of ever reaching that route’s own page:

import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

export function proxy(request: NextRequest) {
  console.log("proxy ran for", request.nextUrl.pathname);
  if (request.nextUrl.pathname === "/old-page") {
    return NextResponse.redirect(new URL("/", request.url));
  }
  return NextResponse.next();
}
$ docker run --rm -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx next build 2>&1 | grep -v -E "Generating static pages using|Collecting page data using|Finished filesystem cache database compaction"
▲ Next.js 16.4.3 (Turbopack)
✓ Running next.config.ts took 89ms
- Cache Components enabled
- Partial Prefetching enabled

  Creating an optimized production build ...
✓ Compiled successfully in 3.2s
  Running TypeScript ...
  Finished TypeScript in 2.6s ...
  Finalizing page optimization ...

Route (app)
┌ ○ /
├ ○ /_not-found
├ ○ /old-page
├ ○ /rewritten
└ ○ /secret


ƒ Proxy (Middleware)

○  (Static)  prerendered as static content
$ docker run -d --name nextjs-proxy-2 -p 3100:3000 -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 node:26.13 npx next start
ed288eabe4e6621e32ce61f2710d01e11180b6a1ec4047eb7c29c417a2dec96d
$ until curl -s -o /dev/null http://localhost:3100/; do sleep 0.3; done
$ curl -s -i http://localhost:3100/old-page | grep -iE '^HTTP|^location'
HTTP/1.1 307 Temporary Redirect
location: /
$ docker rm -f nextjs-proxy-2
nextjs-proxy-2

/old-page still builds ○ like every other route above — the proxy’s redirect is a runtime decision, not something that changes how the page itself gets built. curl never actually receives /old-page’s own content; HTTP/1.1 307 Temporary Redirect plus location: / is what tells a browser (or curl -L) to make a second request, this time for /.

A Rewrite Swaps the Response Without Changing the URL

Add a second rule: a request for /go-to-rewritten gets served /rewritten’s content directly, with NextResponse.rewrite instead of NextResponse.redirect:

import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

export function proxy(request: NextRequest) {
  console.log("proxy ran for", request.nextUrl.pathname);
  if (request.nextUrl.pathname === "/old-page") {
    return NextResponse.redirect(new URL("/", request.url));
  }
  if (request.nextUrl.pathname === "/go-to-rewritten") {
    return NextResponse.rewrite(new URL("/rewritten", request.url));
  }
  return NextResponse.next();
}

There’s no app/go-to-rewritten/page.tsx anywhere in this project — predict whether the build notices that missing route before running it:

$ docker run --rm -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx next build 2>&1 | grep -v -E "Generating static pages using|Collecting page data using|Finished filesystem cache database compaction"
▲ Next.js 16.4.3 (Turbopack)
✓ Running next.config.ts took 57ms
- Cache Components enabled
- Partial Prefetching enabled

  Creating an optimized production build ...
✓ Compiled successfully in 2.5s
  Running TypeScript ...
  Finished TypeScript in 2.1s ...
  Finalizing page optimization ...

Route (app)
┌ ○ /
├ ○ /_not-found
├ ○ /old-page
├ ○ /rewritten
└ ○ /secret


ƒ Proxy (Middleware)

○  (Static)  prerendered as static content
$ docker run -d --name nextjs-proxy-3 -p 3100:3000 -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 node:26.13 npx next start
ec0176fb7e9988f52fb17c7feeccb7619b32e852b7e82ff376612f7dc629e2dc
$ until curl -s -o /dev/null http://localhost:3100/; do sleep 0.3; done
$ curl -s -i http://localhost:3100/go-to-rewritten | grep -iE '^HTTP|^location'
HTTP/1.1 200 OK
$ curl -s http://localhost:3100/go-to-rewritten | grep -o '<div>[^<]*</div>'
<div>rewritten page content</div>
$ curl -s http://localhost:3100/rewritten | grep -o '<div>[^<]*</div>'
<div>rewritten page content</div>
$ docker rm -f nextjs-proxy-3
nextjs-proxy-3

The build never complains about a missing /go-to-rewritten route — nothing in the route table lists it, because the rewrite happens before Next.js’s own router ever looks for a matching page.tsx. curl -i against /go-to-rewritten returns a plain 200 OK with no location: header at all, unlike the redirect above, and its body is the identical rewritten page content text /rewritten itself serves. A browser’s address bar would still read /go-to-rewritten the whole time — the swap happens entirely on the server, invisible to the client making the request.

matcher Limits Which Paths Run the Proxy

Every request so far has run the proxy function, home page included — add a matcher to name exactly which paths should, and fold in the cookie logic the next section explains:

import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

export function proxy(request: NextRequest) {
  console.log("proxy ran for", request.nextUrl.pathname);
  if (request.nextUrl.pathname === "/old-page") {
    return NextResponse.redirect(new URL("/", request.url));
  }
  if (request.nextUrl.pathname === "/go-to-rewritten") {
    return NextResponse.rewrite(new URL("/rewritten", request.url));
  }
  const visits = Number(request.cookies.get("visits")?.value ?? "0") + 1;
  const response = NextResponse.next();
  response.cookies.set("visits", String(visits));
  response.headers.set("x-visit-count", String(visits));
  return response;
}

export const config = {
  matcher: ["/old-page", "/go-to-rewritten", "/secret"],
};

Rebuild, start, and request the home page — it isn’t in matcher — then /secret, which is:

$ docker run --rm -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx next build 2>&1 | grep -v -E "Generating static pages using|Collecting page data using|Finished filesystem cache database compaction"
▲ Next.js 16.4.3 (Turbopack)
✓ Running next.config.ts took 98ms
- Cache Components enabled
- Partial Prefetching enabled

  Creating an optimized production build ...
✓ Compiled successfully in 2.8s
  Running TypeScript ...
  Finished TypeScript in 2.5s ...
  Finalizing page optimization ...

Route (app)
┌ ○ /
├ ○ /_not-found
├ ○ /old-page
├ ○ /rewritten
└ ○ /secret


ƒ Proxy (Middleware)

○  (Static)  prerendered as static content
$ docker run -d --name nextjs-proxy-4 -p 3100:3000 -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 node:26.13 npx next start
1f3ddaf6774d5ea58f96a525766744ad46311e54f4ccb18597a60cdbedbe3def
$ until curl -s -o /dev/null http://localhost:3100/; do sleep 0.3; done
$ curl -s -i http://localhost:3100/ | grep -i 'set-cookie'
$ curl -s -i http://localhost:3100/secret | grep -i 'set-cookie'
set-cookie: visits=1; Path=/
$ docker logs nextjs-proxy-4 2>&1 | grep -c '^proxy ran for /$'
0
$ docker logs nextjs-proxy-4 2>&1 | grep -c '^proxy ran for /secret$'
1

The home page’s response carries no set-cookie: line at all, and the server’s own log shows proxy ran for / zero times — the readiness-wait request to / never reached the function, because / isn’t in matcher. /secret is listed, so it runs once and its response carries set-cookie: visits=1.

Reading and Setting Cookies in the Proxy

The same running container already answered one request to /secret above. Request it again, sending the cookie visits=1 back the way a browser automatically would, and watch the count grow:

$ curl -s -i -b 'visits=1' http://localhost:3100/secret | grep -iE 'set-cookie|x-visit-count'
set-cookie: visits=2; Path=/
x-visit-count: 2
$ curl -s -i -b 'visits=2' http://localhost:3100/secret | grep -iE 'set-cookie|x-visit-count'
set-cookie: visits=3; Path=/
x-visit-count: 3
$ docker rm -f nextjs-proxy-4
nextjs-proxy-4

request.cookies.get("visits") reads the exact value curl -b sent in; response.cookies.set on the returned NextResponse writes a new set-cookie: header, and response.headers.set("x-visit-count", ...) adds a plain header carrying the same number — one function reading an incoming cookie and writing both a new cookie and a new header on the way out, nothing about NextResponse.next() stops either.

A Real Failure: A Wrong Export Name

Rename the exported function from proxy to middleware — the file is still called proxy.ts, nothing else changes. Predict whether a working function under the wrong name still runs:

import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

export function middleware(request: NextRequest) {
  console.log("proxy ran for", request.nextUrl.pathname);
  if (request.nextUrl.pathname === "/old-page") {
    return NextResponse.redirect(new URL("/", request.url));
  }
  if (request.nextUrl.pathname === "/go-to-rewritten") {
    return NextResponse.rewrite(new URL("/rewritten", request.url));
  }
  const visits = Number(request.cookies.get("visits")?.value ?? "0") + 1;
  const response = NextResponse.next();
  response.cookies.set("visits", String(visits));
  response.headers.set("x-visit-count", String(visits));
  return response;
}

export const config = {
  matcher: ["/old-page", "/go-to-rewritten", "/secret"],
};
$ docker run --rm -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx next build
▲ Next.js 16.4.3 (Turbopack)
✓ Running next.config.ts took 72ms
- Cache Components enabled
- Partial Prefetching enabled

  Creating an optimized production build ...

> Build error occurred
Error: Turbopack build failed with 1 error:
./proxy.ts
Error: Proxy is missing expected function export name
This function is what Next.js runs for every request handled by this proxy (previously called middleware).

Why this happens:
- You are migrating from `middleware` to `proxy`, but haven't updated the exported function.
- The file exists but doesn't export a function.
- The export is not a function (e.g., an object or constant).
- There's a syntax error preventing the export from being recognized.

To fix it:
- Ensure this file has either a default or "proxy" function export.

Learn more: https://nextjs.org/docs/messages/middleware-to-proxy


    at ignore-listed frames

TypeScript never complains — middleware is a perfectly valid function, just not the name Next.js is looking for in a file called proxy.ts. The build fails here instead, naming the exact two export shapes it accepts: a function named proxy, or a default export. Restore the export name to get back to a clean build:

import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

export function proxy(request: NextRequest) {
  console.log("proxy ran for", request.nextUrl.pathname);
  if (request.nextUrl.pathname === "/old-page") {
    return NextResponse.redirect(new URL("/", request.url));
  }
  if (request.nextUrl.pathname === "/go-to-rewritten") {
    return NextResponse.rewrite(new URL("/rewritten", request.url));
  }
  const visits = Number(request.cookies.get("visits")?.value ?? "0") + 1;
  const response = NextResponse.next();
  response.cookies.set("visits", String(visits));
  response.headers.set("x-visit-count", String(visits));
  return response;
}

export const config = {
  matcher: ["/old-page", "/go-to-rewritten", "/secret"],
};
$ docker run --rm -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx next build 2>&1 | grep -v -E "Generating static pages using|Collecting page data using|Finished filesystem cache database compaction"
▲ Next.js 16.4.3 (Turbopack)
✓ Running next.config.ts took 59ms
- Cache Components enabled
- Partial Prefetching enabled

  Creating an optimized production build ...
✓ Compiled successfully in 2.6s
  Running TypeScript ...
  Finished TypeScript in 2.3s ...
  Finalizing page optimization ...

Route (app)
┌ ○ /
├ ○ /_not-found
├ ○ /old-page
├ ○ /rewritten
└ ○ /secret


ƒ Proxy (Middleware)

○  (Static)  prerendered as static content

Practice

Each task below builds on the files the previous ones (and the main lesson above) left in place — do them in order.

1. Add a second redirect rule: /legacy redirects to /old-page (which itself still redirects to /). Confirm the direct response and that curl -L follows both hops all the way to the home page.

Solution
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

export function proxy(request: NextRequest) {
  console.log("proxy ran for", request.nextUrl.pathname);
  if (request.nextUrl.pathname === "/old-page") {
    return NextResponse.redirect(new URL("/", request.url));
  }
  if (request.nextUrl.pathname === "/legacy") {
    return NextResponse.redirect(new URL("/old-page", request.url));
  }
  if (request.nextUrl.pathname === "/go-to-rewritten") {
    return NextResponse.rewrite(new URL("/rewritten", request.url));
  }
  const visits = Number(request.cookies.get("visits")?.value ?? "0") + 1;
  const response = NextResponse.next();
  response.cookies.set("visits", String(visits));
  response.headers.set("x-visit-count", String(visits));
  return response;
}

export const config = {
  matcher: ["/old-page", "/legacy", "/go-to-rewritten", "/secret"],
};
$ docker run --rm -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx next build 2>&1 | grep -v -E "Generating static pages using|Collecting page data using|Finished filesystem cache database compaction"
▲ Next.js 16.4.3 (Turbopack)
✓ Running next.config.ts took 56ms
- Cache Components enabled
- Partial Prefetching enabled

  Creating an optimized production build ...
✓ Compiled successfully in 2.6s
  Running TypeScript ...
  Finished TypeScript in 2.3s ...
  Finalizing page optimization ...

Route (app)
┌ ○ /
├ ○ /_not-found
├ ○ /old-page
├ ○ /rewritten
└ ○ /secret


ƒ Proxy (Middleware)

○  (Static)  prerendered as static content
$ docker run -d --name nextjs-proxy-5 -p 3100:3000 -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 node:26.13 npx next start
e757ff557746a33f29c6d51db1db5aea5db5b10467470be1d03f1878321f26cf
$ until curl -s -o /dev/null http://localhost:3100/; do sleep 0.3; done
$ curl -s -i http://localhost:3100/legacy | grep -iE '^HTTP|^location'
HTTP/1.1 307 Temporary Redirect
location: /old-page
$ curl -s -L http://localhost:3100/legacy | grep -o '<div>[^<]*</div>'
<div>home page content</div>
$ docker rm -f nextjs-proxy-5
nextjs-proxy-5

The direct response to /legacy only names its own redirect target, /old-page — it has no idea that path redirects again. curl -L makes a new request each time it follows a location: header, so it quietly makes both hops and lands on home page content, the same place a browser’s address bar would end up after two silent redirects.

2. Add a middleware.ts file next to the existing proxy.ts (don’t delete proxy.ts). Confirm the build rejects having both, then remove middleware.ts again.

Solution
import { NextResponse } from "next/server";

export function middleware() {
  return NextResponse.next();
}
$ docker run --rm -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx next build
▲ Next.js 16.4.3 (Turbopack)
✓ Running next.config.ts took 66ms
- Cache Components enabled
- Partial Prefetching enabled


> Build error occurred
Error: Both middleware file "./middleware.ts" and proxy file "./proxy.ts" are detected. Please use "./proxy.ts" only. Learn more: https://nextjs.org/docs/messages/middleware-to-proxy
    at ignore-listed frames
$ rm middleware.ts

Next.js refuses to guess which one should win — having both is a dedicated, named build error, distinct from the generic export-name error above, and it names both files so there’s no doubt which two are conflicting.

3. Rename proxy.ts itself to middleware.ts (keep the function body, rename the exported function to middleware too), with no proxy.ts left at all. Confirm the build still succeeds with only a warning, and that a matched path still runs it. Rename back to proxy.ts/proxy afterward.

Solution
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

export function middleware(request: NextRequest) {
  console.log("proxy ran for", request.nextUrl.pathname);
  if (request.nextUrl.pathname === "/old-page") {
    return NextResponse.redirect(new URL("/", request.url));
  }
  if (request.nextUrl.pathname === "/legacy") {
    return NextResponse.redirect(new URL("/old-page", request.url));
  }
  if (request.nextUrl.pathname === "/go-to-rewritten") {
    return NextResponse.rewrite(new URL("/rewritten", request.url));
  }
  const visits = Number(request.cookies.get("visits")?.value ?? "0") + 1;
  const response = NextResponse.next();
  response.cookies.set("visits", String(visits));
  response.headers.set("x-visit-count", String(visits));
  return response;
}

export const config = {
  matcher: ["/old-page", "/legacy", "/go-to-rewritten", "/secret"],
};
$ docker run --rm -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx next build 2>&1 | grep -v -E "Generating static pages using|Collecting page data using|Finished filesystem cache database compaction"
▲ Next.js 16.4.3 (Turbopack)
✓ Running next.config.ts took 66ms
- 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 in 3.8s
  Running TypeScript ...
  Finished TypeScript in 2.6s ...
  Finalizing page optimization ...

Route (app)
┌ ○ /
├ ○ /_not-found
├ ○ /old-page
├ ○ /rewritten
└ ○ /secret


ƒ Proxy (Middleware)

○  (Static)  prerendered as static content
$ docker run -d --name nextjs-proxy-6 -p 3100:3000 -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 node:26.13 npx next start
4a1f1464e4cf971e14a1623560f11636cc54a240713edd409c2ab4600b5971dc
$ until curl -s -o /dev/null http://localhost:3100/; do sleep 0.3; done
$ curl -s -i http://localhost:3100/old-page | grep -iE '^HTTP|^location'
HTTP/1.1 307 Temporary Redirect
location: /
$ docker rm -f nextjs-proxy-6
nextjs-proxy-6

A plain middleware.ts, by itself, only earns a warning — ⚠ The "middleware" file convention is deprecated — and the build still succeeds, still labeled ƒ Proxy (Middleware), and /old-page still redirects exactly like it did under proxy.ts. Unlike Practice 2’s error, this is a working build with a nudge to migrate, not a rejection.

4. Require a header on /secret: without x-app-key: letmein, return 403 Forbidden instead of running the cookie logic. Confirm both the rejection and a request that supplies the header, then remove every container and volume this lesson created.

Solution
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

export function proxy(request: NextRequest) {
  console.log("proxy ran for", request.nextUrl.pathname);
  if (request.nextUrl.pathname === "/old-page") {
    return NextResponse.redirect(new URL("/", request.url));
  }
  if (request.nextUrl.pathname === "/legacy") {
    return NextResponse.redirect(new URL("/old-page", request.url));
  }
  if (request.nextUrl.pathname === "/go-to-rewritten") {
    return NextResponse.rewrite(new URL("/rewritten", request.url));
  }
  if (request.nextUrl.pathname === "/secret" && request.headers.get("x-app-key") !== "letmein") {
    return new NextResponse("Forbidden", { status: 403 });
  }
  const visits = Number(request.cookies.get("visits")?.value ?? "0") + 1;
  const response = NextResponse.next();
  response.cookies.set("visits", String(visits));
  response.headers.set("x-visit-count", String(visits));
  return response;
}

export const config = {
  matcher: ["/old-page", "/legacy", "/go-to-rewritten", "/secret"],
};
$ docker run --rm -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx next build 2>&1 | grep -v -E "Generating static pages using|Collecting page data using|Finished filesystem cache database compaction"
▲ Next.js 16.4.3 (Turbopack)
✓ Running next.config.ts took 70ms
- Cache Components enabled
- Partial Prefetching enabled

  Creating an optimized production build ...
✓ Compiled successfully in 3.2s
  Running TypeScript ...
  Finished TypeScript in 2.3s ...
  Finalizing page optimization ...

Route (app)
┌ ○ /
├ ○ /_not-found
├ ○ /old-page
├ ○ /rewritten
└ ○ /secret


ƒ Proxy (Middleware)

○  (Static)  prerendered as static content
$ docker run -d --name nextjs-proxy-7 -p 3100:3000 -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 node:26.13 npx next start
7889ab98143e867ee59a2546b50c4a4aee8e734e0df911a7c17070fa74448419
$ until curl -s -o /dev/null http://localhost:3100/; do sleep 0.3; done
$ curl -s -i http://localhost:3100/secret | grep -iE '^HTTP'
HTTP/1.1 403 Forbidden
$ curl -s -i -H 'x-app-key: letmein' http://localhost:3100/secret | grep -iE '^HTTP|set-cookie'
HTTP/1.1 200 OK
set-cookie: visits=1; Path=/
$ docker rm -f nextjs-proxy-1 nextjs-proxy-2 nextjs-proxy-3 nextjs-proxy-4 nextjs-proxy-5 nextjs-proxy-6 nextjs-proxy-7
nextjs-proxy-7
$ docker volume rm -f nextjs-modules
nextjs-modules

Without the header, /secret never reaches the cookie logic at all — a plain new NextResponse("Forbidden", { status: 403 }) short-circuits before NextResponse.next() ever runs. Supplying the correct header falls through to the same cookie logic from before, starting a fresh visits=1. A header check like this in the proxy is a coarse gate, not a substitute for authorization close to the data it protects — a proxy-only check has been bypassed before (CVE-2025-29927, a 2025 Next.js vulnerability that let a crafted request skip the middleware/proxy layer entirely), so anything that genuinely needs protecting still has to check permissions again wherever the data is actually read or written, not rely solely on a header this file inspects. The last two steps remove every container this lesson started (most already removed by earlier sections, which is harmless) and the shared node_modules volume.

Frequently Asked Questions

What's the difference between a redirect and a rewrite in a Next.js proxy?
A redirect (NextResponse.redirect) sends the client a 307 (or another 3xx) status with a Location header, telling the browser or curl to make a brand-new request to a different URL — the address bar changes. A rewrite (NextResponse.rewrite) serves a different route's content under the original URL with a plain 200 and no Location header at all; the client never sees the swap, so the address bar never changes. curl -i shows the difference directly: a redirect's response has a location: header and no body worth reading, a rewrite's response has a body (the target route's real content) and no location: header.
Does a leftover middleware.ts break a Next.js 16 build?
It depends on whether a proxy.ts also exists. middleware.ts alone still works — Next.js 16.4.3 only logs a deprecation warning (The "middleware" file convention is deprecated. Please use "proxy" instead.) and otherwise builds and runs it exactly like a proxy.ts. If both middleware.ts and proxy.ts exist in the same project, the build fails outright with Both middleware file ... and proxy file ... are detected. Please use "./proxy.ts" only. — it's only an error once there are two competing files, not a deprecated one by itself.
What does matcher actually do, and why did the proxy run for every path before this lesson added one?
matcher is an allow-list: once it's set, Next.js only invokes the proxy function for the paths named in it, and any path not listed skips the function entirely and goes straight to its normal route. Before this lesson wrote a matcher at all, there was nothing limiting which paths ran the function, so it ran for every single request — this lesson's first version showed exactly that, logging "proxy ran for /" for a request to the home page that was never deliberately testing it. matcher is something a proxy opts into restricting itself with, not a list of paths it opts out of running for.
Can a proxy read a cookie the client already sent, and also set a new one in the same response?
Yes — request.cookies.get("name")?.value reads whatever cookie the client's own Cookie header carried, and response.cookies.set("name", value) on the NextResponse the function returns adds a Set-Cookie header to that same response. Both work on a plain NextResponse.next() just as well as on a redirect or rewrite response, since all three are regular Response objects under the hood.
What exported function name does proxy.ts need?
Next.js looks specifically for a function named proxy or a default export in a file named proxy.ts, and treats every other export as irrelevant. This lesson's own build only exercises the named-function path (export function proxy), which succeeds every time that name is present. Any other export name (middleware, for instance, left over from a rename) fails the build instead, with the exact wording this lesson's build output shows: "Error: Proxy is missing expected function export name ... Ensure this file has either a default or "proxy" function export."