Tiếng Việt

Home

Middleware becomes proxy: a rename that changes the runtime

Next.js 16 renames middleware to proxy. The upgrade question is not “how do I rename it” but “does the rename change behaviour”. This page builds a small app on next@16.4.4, measures four proxy behaviours over HTTP, then changes exactly one word in the function name to see what else moves: the runtime. The code below is the code that ran, and every number comes from one run of the measuring script.

The code on this page is an app that runs: 32/32 checks passed on 2026-11-11. Versions run: next@16.4.4, react@19.3.0, Node.js v26.0.0.

A rename, not a new feature

The Next.js docs say it in the first line of the proxy.js page: “The middleware file convention is deprecated and has been renamed to proxy.” The file still sits at the project root (beside app/ or pages/), still exports one function, still takes a NextRequest and returns a NextResponse — only the file name and the function name change, and Next.js ships a codemod for that.

Measured on next@16.4.4: an app with only proxy.ts builds with exit 0 and prints no warning about the file name. But the route table of next build still prints ƒ Proxy (Middleware) — the word middleware is still in the build output, so a CI script that greps for it still finds it. Do not read that line as a sign that you have not renamed the file.

The interesting part is not the name but the runtime: proxy.ts runs on Node.js, middleware.ts still runs on Edge. That is section 4, and it is why this page does not stop at rename instructions.

One proxy, four jobs

The sample app has five routes: a home page that reads a header with headers(), /dich as a static page, /moi as a redirect target, /quan-tri as a page that needs a session, and /api/phien as a Route Handler. One file, public/tai-nguyen.txt, exists to test the matcher; one proxy does the four jobs a proxy is usually asked to do.

The four, in file order: NextResponse.redirect for the old address /cu, NextResponse.rewrite for /rewrite, a gate that returns 401 without the phien cookie, and the pass-upstream part — an x-nguon header the page reads, a da-ghe cookie the browser keeps, an x-proxy header on the response.

The x-nguon header is not a fixed string: it is the first 12 characters of sha256(pathname), computed with node:crypto inside the proxy. That is the cheapest proof of which runtime is running — Edge cannot load node:crypto.

proxy.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { createHash } from "node:crypto";

// Proxy chạy trước mọi route khớp matcher. Mặc định là Node.js runtime, nên `node:crypto` dùng được ở đây.
export function proxy(request: NextRequest) {
  const { pathname } = request.nextUrl;

  // 1. Chuyển hướng: địa chỉ cũ không còn, trình duyệt nhận 308 và đi tiếp.
  if (pathname === "/cu") {
    return NextResponse.redirect(new URL("/moi", request.url), 308);
  }

  // 2. Rewrite: thanh địa chỉ vẫn là /rewrite, nội dung trả về là của /dich.
  if (pathname === "/rewrite") {
    return NextResponse.rewrite(new URL("/dich", request.url));
  }

  // 3. Cổng chặn: không có cookie hợp lệ thì 401, trang không bao giờ được render.
  if (pathname.startsWith("/quan-tri") && request.cookies.get("phien")?.value !== "hop-le") {
    return Response.json({ loi: "chua dang nhap" }, { status: 401 });
  }

  // 4. Chuyển tiếp dữ liệu lên trên: một header cho trang đọc, một cookie cho trình duyệt giữ.
  const headers = new Headers(request.headers);
  headers.set("x-nguon", createHash("sha256").update(pathname).digest("hex").slice(0, 12));

  const response = NextResponse.next({ request: { headers } });
  response.headers.set("x-proxy", "1");
  response.cookies.set("da-ghe", "1", { path: "/" });
  return response;
}

export const config = {
  // Mọi đường trừ tài nguyên build; favicon.ico bị loại vì nó do Next.js tự phục vụ.
  matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};

Measured over HTTP: redirect, rewrite, gate

/cu returns 308 with location: /moi. It also carries a header few people look at: Refresh: 0;url=/moi, which Next.js adds to the redirect response itself. If you assert on the whole header set, that header will break the assertion.

/rewrite returns 200 with the body of /dich, the address unchanged, and no location. The response carries the internal header x-middleware-rewrite: /dich — the old name is still in the header name, just as the old word is still in the route table.

The gate: /quan-tri returns 401 without the cookie, 200 with phien=hop-le, and the page renders only in the second case. The home page returns 200 with set-cookie: da-ghe=1; Path=/ and x-proxy: 1. The x-nguon header reaches both a Server Component (8a5edab28263, the hash of /) and a Route Handler (1a9700d684b3, the hash of /api/phien) — the data crosses exactly the boundary the docs describe.

One measuring detail: the home page reads headers(), so it becomes dynamic (ƒ / in the route table), while /dich stays static (○ /dich) and still receives x-proxy: 1. The proxy runs before a static page too — if you need a static page protected, the proxy is the only place that does it without giving up prerendering.

app/page.tsx
import { headers } from "next/headers";

/** Trang chủ đọc header mà proxy đặt, để thấy dữ liệu đã đi qua ranh giới đó. */
export default async function Home() {
  const nguon = (await headers()).get("x-nguon") ?? "(khong co)";
  return (
    <main>
      <h1>Proxy, đo trên một ứng dụng thật</h1>
      <p>
        Header <code>x-nguon</code> do <code>proxy.ts</code> đặt: <code>{nguon}</code>
      </p>
      <ul>
        <li>
          <a href="/cu">/cu</a> — chuyển hướng 308 sang /moi
        </li>
        <li>
          <a href="/rewrite">/rewrite</a> — rewrite sang /dich, địa chỉ giữ nguyên
        </li>
        <li>
          <a href="/quan-tri">/quan-tri</a> — 401 nếu thiếu cookie <code>phien</code>
        </li>
      </ul>
    </main>
  );
}
app/api/phien/route.ts
import type { NextRequest } from "next/server";

/** Route Handler đọc lại header proxy đặt, để so với trang server render. */
export function GET(request: NextRequest) {
  return Response.json({
    nguon: request.headers.get("x-nguon") ?? null,
    phien: request.cookies.get("phien")?.value ?? null,
  });
}
app/quan-tri/page.tsx
import { cookies } from "next/headers";

/** Chỉ tới được đây khi proxy đã cho qua, tức là đã có cookie `phien=hop-le`. */
export default async function QuanTri() {
  const phien = (await cookies()).get("phien")?.value ?? "(khong co)";
  return (
    <main>
      <h1>Khu vực quản trị</h1>
      <p>
        Phiên: <code>{phien}</code>
      </p>
    </main>
  );
}

The line that really changes: the runtime

This is the measurement worth the page. Take the proxy.ts above, rename it to middleware.ts, change export function proxy( to export function middleware( — and change nothing else — then build: exit 0. The build prints two warnings: ⚠ The “middleware” file convention is deprecated. Please use “proxy” instead. and Warning: A Node.js module is loaded ('node:crypto' at line 3) which is not supported in the Edge Runtime.

Run that build: every request returns 500, and the server log says Error: Failed to load external module node:crypto: TypeError: Native module not found: node:crypto. Same code, same commit, one different file name: proxy.ts answers 200, middleware.ts answers 500. The reason is the runtime — middleware.ts keeps the Edge runtime, proxy.ts defaults to Node.js.

Both directions matter when you upgrade. The good one: code using node:*, process.env, or a Node-only package is now legal in the proxy — it could never run in middleware.ts, where it only warned and then died at request time. The bad one: a leftover middleware.ts still builds green, its warning sits in the log, and it breaks only on a real request — exactly when you have just added a line of import that looked harmless.

So the check after the rename is not next build. A green build does not prove the proxy runs, and next dev is even more forgiving (next section). Run next start and call a route the proxy guards.

The matcher: what goes through the proxy

With the sample app's matcher, /((?!_next/static|_next/image|favicon.ico).*): public/tai-nguyen.txt does go through the proxy (the response carries x-proxy: 1 and the cookie), while /_next/static/chunks/*.js does not. The docs are explicit: without a matcher the proxy runs on every request, static assets and public/ included — and a careless gate will block the CSS, JS and images of its own login page.

Matcher values must be constants so Next.js can analyse them at build time; a variable is silently ignored. And there is one documented exception: even when you exclude _next/data from the matcher, the proxy still runs for those routes — on purpose, so a protected page does not leak through the data path.

On CVE-2025-29927 (the x-middleware-subrequest header bypass): on next@16.4.4, sending that header to /quan-tri still returns 401. The header no longer skips the proxy. But do not rebuild the old trust: the proxy is not the only auth layer. The docs note that a Server Function is a POST to the route that contains it, so a matcher that excludes that route silently drops the guard — check authorisation inside the Server Function and in the data access layer, not only in the proxy.

Two build errors, and one `next dev` trap

Add export const runtime = “edge” to proxy.ts: next build exits 1 with Error: Route segment config is not allowed in Proxy file at “./proxy.ts”. Proxy always runs on Node.js runtime. There is no flag to change it — the proxy always runs on Node.js; that is a decision of 16, not an option.

The trap: the same file under next dev prints no such error. Dev accepts the file, runs it on Edge, and every request returns 500 because node:crypto cannot load. A workflow that only runs next dev never sees the error line; only next build refuses it. That is a second reason to run next build before trusting an upgrade.

Keep both middleware.ts and proxy.ts in one project: next build exits 1 with Error: Both middleware file “./middleware.ts” and proxy file “./proxy.ts” are detected. Please use “./proxy.ts” only. There is no precedence rule to reason about — only one file may exist.

Move a real app: the codemod, then the checks

The documented codemod is npx @next/codemod@canary middleware-to-proxy .. Measured in a directory that is not a git repository, it refuses and prints But before we continue, please stash or commit your git changes. With --force it runs: it renames middleware.ts to proxy.ts, renames the function, and one line changes — the body, the matcher and the import { createHash } from “node:crypto” line stay identical byte for byte. The post-codemod app builds with exit 0 and the home page returns 200, on the very file that returned 500 in section 4.

Because the codemod only renames, the rest is yours. Five steps, cheapest first: 1. grep -rn middleware for the file and every mention of the old name, tests and CI scripts included. 2. Run the codemod on a clean branch — it needs git, or --force. 3. Read the file: Node-only APIs are now legal, and anything that relied on Edge behaviour needs a second look. 4. next build and next start, then call a route the proxy guards — a green build proves nothing on its own. 5. Check the matcher against your Server Function routes: which page path do they POST to, and is it excluded?

If the app has an auth gate in middleware, add one more: send the x-middleware-subrequest header to a guarded route and check it still returns 401. That is the CVE-2025-29927 test, and it is one curl line.

When not to use a proxy

The Next.js docs do not hide the position: “We recommend users avoid relying on Middleware unless no other options exist”, and the rename is part of that direction. The proxy is a network boundary in front of the app — good for redirects, rewrites, geo A/B tests, IP-range blocking; a poor place to put your only permission check, because it does not see data and does not know who the user is once a session changes.

For most apps, what you once did in middleware now has a better home: locale redirects in a layout, permission checks in the data access layer, cookie reads in a Server Component. A rename is a good moment to delete the work the proxy no longer needs to do — one layer fewer is one place fewer to get wrong.

And if you do need exactly what a proxy does: the file is renamed, the runtime changed, and this page measured both.

How this page was measured

Every number here comes from one script. It copies the sample app into a work directory and installs the pinned version. After each one-file change it rebuilds: four variants, then one codemod run. After each build it starts next start and calls each route; the runtime = “edge” variant also gets one next dev run. The check list below is printed from that run. The script rebuilds every number from scratch and fails if any of the four behaviours stops holding.

The results hold only for the version at the top of the page. A new Next.js does not change the page by itself: we move the pin, measure again, and a check that no longer passes keeps the page off the site.

Checked

  • proxy.ts builds: exit 0
  • what `next build` calls it in the route table: ƒ Proxy (Middleware)
  • a deprecation warning for proxy.ts: no deprecation warning
  • a page that reads `headers()` is rendered per request, a page that does not stays static: ƒ /, ○ /dich
  • a request header set by the proxy reaches a Server Component (`headers()`): x-nguon 8a5edab28263
  • …and the same header reaches a Route Handler: nguon 1a9700d684b3
  • the hash comes from `node:crypto` inside the proxy: sha256 computed at request time
  • a response header the proxy set: x-proxy: 1
  • a cookie the proxy set: set-cookie: da-ghe=1; Path=/
  • NextResponse.redirect on /cu: 308, location /moi
  • …and Next.js adds a Refresh header of its own: Refresh: 0;url=/moi
  • NextResponse.rewrite on /rewrite keeps the address and serves /dich: 200, location (none), the /dich text
  • the internal rewrite header keeps the old name: x-middleware-rewrite: /dich
  • the gate: /quan-tri without the cookie: 401
  • the gate: /quan-tri with `phien=hop-le`: 200, page rendered
  • the matcher: a file in public/ is still matched: x-proxy: 1
  • the matcher: a build asset is not matched: x-proxy: (none)
  • CVE-2025-29927: `x-middleware-subrequest` no longer skips the gate: 401 with the header
  • it still builds: exit 0
  • what the build warns first: ⚠ The "middleware" file convention is deprecated. Please use "proxy" instead.
  • what else it warns: Warning: A Node.js module is loaded ('node:crypto' at line 3) which is not supported in the Edge Runtime.
  • what one request returns at runtime: 500
  • why it is 500: Error: Failed to load external module node:crypto: TypeError: Native module not found: node:crypto
  • `next build` with runtime = "edge" in a proxy file: exit 1: Error: Route segment config is not allowed in Proxy file at "./proxy.ts". Proxy always runs on Node.js runtime. Learn more: https://nextjs.org/docs/messages/middleware-to-proxy
  • `next dev` with the same file: 500 from next dev
  • both files in one app: exit 1: 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
  • the codemod outside a git repository (the work dir is not one): But before we continue, please stash or commit your git changes.
  • the codemod with --force: exit 0
  • what it renames: middleware.ts → proxy.ts, 1 line changed: export function proxy(request: NextRequest) {
  • what it leaves alone: identical apart from the function name
  • the codemod's output builds: exit 0
  • …and it serves the page that 500'd as middleware.ts: 200

Measuring script (not public yet): bun scripts/guides/verify-proxy.ts

Related

Sources

Our summary and wording; the code is rewritten from the examples in the Next.js docs. Where they differ, the Next.js docs win.