Next.js · 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
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.