Middleware thành proxy: đổi tên, và đổi cả runtime
Next.js 16 đổi tên middleware thành proxy. Câu hỏi khi nâng cấp không phải “đổi tên thế nào” mà “đổi tên thì hành vi có đổi không”. Trang này dựng một ứng dụng nhỏ trên next@16.4.4, đo bốn hành vi của proxy trên HTTP, rồi đổi đúng một chữ trong tên hàm để xem cái gì khác đi: runtime. Mã in bên dưới là mã đã chạy, và mọi con số đều lấy từ một lần chạy script đo.
Mã trên trang này là một ứng dụng chạy được: 32/32 kiểm tra đạt ngày 11/11/2026. Phiên bản đã chạy thử: next@16.4.4, react@19.3.0, Node.js v26.0.0.
Đổi tên, không đổi tính năng
Tài liệu Next.js ghi ngay dòng đầu của trang proxy.js: “The middleware file convention is deprecated and has been renamed to proxy.” Tệp vẫn nằm ở gốc dự án (ngang hàng app/ hoặc pages/), vẫn export một hàm, vẫn nhận NextRequest và trả NextResponse — chỉ tên tệp và tên hàm đổi, và Next.js có codemod lo phần đó.
Đo trên next@16.4.4: một ứng dụng chỉ có proxy.ts build xong với mã thoát 0 và không in cảnh báo nào về tên tệp. Nhưng bảng route của next build vẫn in ƒ Proxy (Middleware) — chữ “middleware” còn trong output build, nên một script CI grep chữ đó vẫn thấy. Đừng đọc dòng ấy như dấu hiệu bạn chưa đổi tên.
Điều đáng chú ý hơn nằm ở runtime, không ở tên: proxy.ts chạy trên Node.js, còn middleware.ts vẫn chạy trên Edge. Đó là nội dung của mục 4 — nó là lý do trang này không dừng ở việc hướng dẫn rename.
Một proxy làm bốn việc
Ứng dụng mẫu có năm route: trang chủ đọc header bằng headers(), /dich là trang tĩnh, /moi là đích chuyển hướng, /quan-tri là trang cần phiên, /api/phien là Route Handler. Một tệp public/tai-nguyen.txt để thử matcher, và một proxy làm đúng bốn việc mà proxy hay được dùng để làm.
Bốn việc, theo thứ tự trong tệp: NextResponse.redirect cho địa chỉ cũ /cu, NextResponse.rewrite cho /rewrite, một cổng trả 401 khi thiếu cookie phien, và phần chuyển tiếp dữ liệu lên trên — header x-nguon cho trang đọc, cookie da-ghe cho trình duyệt giữ, header x-proxy trên response.
Header x-nguon không phải chuỗi cố định: nó là sha256(pathname) lấy 12 ký tự đầu, tính bằng node:crypto ngay trong proxy. Đó là cách rẻ nhất để chứng minh proxy đang chạy trên Node.js chứ không phải Edge — Edge không nạp được node:crypto.
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).*)"],
};Đo trên HTTP: chuyển hướng, rewrite, cổng chặn
/cu trả 308 với location: /moi. Kèm theo là một header ít ai để ý: Refresh: 0;url=/moi, do Next.js tự thêm vào response chuyển hướng. Nếu bạn đang kiểm tra chuyển hướng bằng cách so toàn bộ header, header này sẽ làm phép so sánh hỏng.
/rewrite trả 200 với nội dung của /dich, thanh địa chỉ không đổi, và không có location. Response mang header nội bộ x-middleware-rewrite: /dich — tên cũ vẫn nằm trong tên header, đúng như chữ “Middleware” còn trong bảng route.
Cổng chặn: /quan-tri trả 401 khi không có cookie, 200 khi gửi phien=hop-le, và trang chỉ render trong trường hợp thứ hai. Trang chủ trả 200 kèm set-cookie: da-ghe=1; Path=/ và x-proxy: 1. Header x-nguon tới được cả Server Component (8a5edab28263 — băm của /) lẫn Route Handler (1a9700d684b3 — băm của /api/phien), tức là dữ liệu đi qua đúng ranh giới mà tài liệu mô tả.
Một chi tiết về cách đo: trang chủ đọc headers() nên nó thành động (ƒ / trong bảng route), còn /dich vẫn tĩnh (○ /dich) mà vẫn nhận x-proxy: 1. Proxy chạy trước cả trang tĩnh — nếu bạn cần một trang tĩnh được bảo vệ, proxy là chỗ duy nhất làm được điều đó mà không bỏ prerender.
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>
);
}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,
});
}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>
);
}Dòng thật sự đổi khi đổi tên: runtime
Đây là phép đo đáng giá nhất của trang. Lấy nguyên tệp proxy.ts ở trên, đổi tên thành middleware.ts và đổi export function proxy( thành export function middleware( — không sửa gì khác — rồi build: mã thoát 0. Build chỉ in hai cảnh báo: ⚠ The “middleware” file convention is deprecated. Please use “proxy” instead. và Warning: A Node.js module is loaded ('node:crypto' at line 3) which is not supported in the Edge Runtime.
Chạy bản build đó lên: mọi request trả 500, log server ghi Error: Failed to load external module node:crypto: TypeError: Native module not found: node:crypto. Cùng một mã, cùng một commit, chỉ khác tên tệp: proxy.ts trả 200, middleware.ts trả 500. Lý do là middleware.ts vẫn giữ runtime Edge, còn proxy.ts mặc định Node.js.
Hệ quả khi nâng cấp, theo cả hai chiều. Chiều tốt: mã dùng node:*, process.env hay một gói chỉ chạy trên Node giờ hợp lệ trong proxy — trước đây nó không thể chạy trong middleware.ts, chỉ báo cảnh báo rồi chết lúc chạy. Chiều xấu: một tệp middleware.ts còn sót lại vẫn build xanh, cảnh báo nằm lẫn trong log, và chỉ đổ vỡ khi có request thật — đúng lúc bạn vừa thêm một dòng import tưởng như vô hại.
Vì vậy phép kiểm sau khi đổi tên không phải next build mà là một request thật: next build xanh không chứng minh proxy chạy được, và next dev còn dễ dãi hơn (mục sau). Chạy next start rồi gọi một route mà proxy có chặn.
Matcher: cái gì đi qua proxy, cái gì không
Với matcher của ứng dụng mẫu là /((?!_next/static|_next/image|favicon.ico).*): public/tai-nguyen.txt có đi qua proxy (response mang x-proxy: 1 và cả set-cookie), còn /_next/static/chunks/*.js thì không. Tài liệu nói rõ: không có matcher thì proxy chạy trên mọi request, kể cả tài nguyên tĩnh và public/ — và một cổng chặn viết ẩu sẽ chặn luôn CSS, JS, ảnh của chính trang đăng nhập.
Giá trị matcher phải là hằng số để Next.js phân tích lúc build; biến số bị bỏ qua mà không báo. Và có một ngoại lệ được ghi trong tài liệu: dù bạn loại _next/data khỏi matcher, proxy vẫn chạy cho các route đó — cố ý, để một trang được bảo vệ không bị lộ qua đường dữ liệu.
Về CVE-2025-29927 (lỗ hổng vượt middleware bằng header x-middleware-subrequest): trên next@16.4.4, gửi header đó tới /quan-tri vẫn nhận 401. Header không còn quyền bỏ qua proxy. Nhưng đừng dựng lại niềm tin cũ: proxy không phải lớp xác thực duy nhất. Tài liệu nhắc rằng Server Function là một POST tới chính route chứa nó, nên một matcher loại route đó ra là âm thầm bỏ luôn lớp bảo vệ — hãy kiểm tra quyền bên trong Server Function và trong lớp truy cập dữ liệu, đừng chỉ ở proxy.
Hai lỗi chặn build, và một cái bẫy của `next dev`
Thêm export const runtime = “edge” vào proxy.ts: next build thoát 1 với Error: Route segment config is not allowed in Proxy file at “./proxy.ts”. Proxy always runs on Node.js runtime. Không có cờ nào để đổi — proxy luôn chạy Node.js, đó là quyết định của bản 16 chứ không phải một tuỳ chọn.
Cái bẫy: cùng tệp đó, next dev không báo lỗi ấy. Nó nhận tệp, chạy trên Edge, và mọi request trả 500 vì node:crypto không nạp được. Một quy trình chỉ dùng next dev sẽ không bao giờ thấy dòng lỗi kia; chỉ next build mới chặn. Đây là lý do thứ hai để chạy next build trước khi tin một bản nâng cấp.
Để cả middleware.ts và proxy.ts trong cùng dự án: next build thoát 1 với Error: Both middleware file “./middleware.ts” and proxy file “./proxy.ts” are detected. Please use “./proxy.ts” only. Không có thứ tự ưu tiên nào để đoán — chỉ có một tệp được phép tồn tại.
Chuyển một ứng dụng thật: codemod, rồi kiểm
Codemod trong tài liệu là npx @next/codemod@canary middleware-to-proxy .. Đo trên một thư mục không phải git repo: nó từ chối, in But before we continue, please stash or commit your git changes. Thêm --force thì nó chạy: đổi middleware.ts thành proxy.ts, đổi tên hàm, và chỉ một dòng thay đổi — phần thân, matcher và dòng import { createHash } from “node:crypto” giữ nguyên từng byte. Bản sau codemod build thoát 0 và trang chủ trả 200, tức là đúng tệp vừa trả 500 ở mục 4.
Vì codemod chỉ đổi tên, phần việc còn lại là của bạn. Năm bước, theo thứ tự rẻ tới đắt: 1. grep -rn middleware để tìm tệp và mọi chỗ nhắc tên cũ (kể cả trong test và script CI). 2. Chạy codemod trên một nhánh sạch — nó cần git, hoặc --force. 3. Đọc lại tệp: chỗ nào dùng API chỉ có trên Node thì giờ đã hợp lệ, chỗ nào dựa vào hành vi Edge thì phải xem lại. 4. next build và next start rồi gọi một route mà proxy chặn — build xanh không chứng minh điều gì. 5. Kiểm matcher: Server Function nằm ở route nào, route đó có bị loại khỏi matcher không.
Nếu ứng dụng có cổng xác thực trong middleware, thêm một bước nữa: gửi header x-middleware-subrequest tới một route bị chặn và xem nó còn 401. Đó là phép thử của CVE-2025-29927, và nó là một dòng curl.
Khi nào đừng dùng proxy
Tài liệu Next.js không giấu quan điểm: “We recommend users avoid relying on Middleware unless no other options exist”, và việc đổi tên là một phần của hướng đi đó. Proxy là ranh giới mạng chạy trước ứng dụng — hợp cho chuyển hướng, rewrite, A/B theo địa lý, chặn theo dải IP; không hợp để làm nơi duy nhất kiểm tra quyền, vì nó không thấy dữ liệu và không biết người dùng cuối là ai sau khi phiên đổi.
Với phần lớn ứng dụng, việc bạn từng làm trong middleware nay có chỗ tốt hơn: chuyển hướng theo ngôn ngữ ở layout, kiểm tra quyền trong lớp truy cập dữ liệu, đọc cookie trong Server Component. Đổi tên tệp là dịp tốt để xoá những việc proxy không cần làm nữa — bớt một tầng là bớt một chỗ có thể sai.
Còn nếu bạn cần đúng thứ proxy làm: tệp đã đổi tên, runtime đã đổi, và trang này đã đo cả hai.
Cách đo trang này
Mọi con số trên trang đến từ một script. Script chép ứng dụng mẫu vào một thư mục làm việc và cài đúng phiên bản đã ghim. Mỗi lần đổi một tệp, nó build lại: bốn biến thể, rồi một lần chạy codemod. Sau mỗi bản build, nó chạy next start và gọi từng route; riêng biến thể runtime = “edge” chạy thêm một lần next dev. Danh sách phép đo bên dưới in từ kết quả của lần chạy đó. Script đo dựng lại mọi con số từ đầu, và báo lỗi nếu một trong bốn hành vi kia không còn đúng.
Kết quả chỉ đúng với phiên bản ghi ở đầu trang. Next.js ra bản mới thì trang không tự đổi: chúng tôi phải đổi phiên bản đã ghim và đo lại, và phép đo nào không đạt thì trang không được lên site.
Đã kiểm tra
- 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
Script đo (chưa công khai): bun scripts/guides/verify-proxy.ts
Liên quan
- Nâng cấp lên Next.js 16: những gì thật sự làm build dừng
- Xác thực trong Next.js (App Router): phiên, phân quyền, Proxy
- CVE-2025-29927: Vượt qua kiểm tra quyền trong Middleware
- Lỗ hổng bảo mật Next.js
- Kiểm tra phiên bản Next.js của trang bạn
Nguồn
- Next.js docs — proxy.js (Middleware renamed to Proxy in v16.0.0)
- Next.js docs — Upgrading to Version 16
- Next.js docs — Self-hosting (where proxy runs and what it may use)
- GitHub Advisory — GHSA-f82v-jwr5-mffw / CVE-2025-29927 (middleware bypass)
- npm registry — next dist-tags
Tóm tắt và diễn giải bằng lời của chúng tôi; mã viết lại từ ví dụ trong tài liệu Next.js. Khi có khác biệt, tài liệu Next.js là chuẩn.