我们继续完善前面章节里的项目协作台。现在,用户打开下面这个地址:
https://acme.example.com/workspace/projects/atlas?view=board
请求还没有找到 page.tsx,Next.js 就要先回答几个问题:这个域名属于哪个租户?用户是否带着可用的登录提示?地址是否需要补上语言前缀?当前用户应该进入新版看板还是旧版看板?
这些判断有一个共同点:它们依赖本次请求,而且必须发生在路由渲染之前。Next.js 16 把负责这类工作的文件叫作 proxy.ts。
你可以把 Proxy 理解成应用入口处的一道窄检查站。它适合快速查看请求、改变路由方向、补充少量头部或提前返回响应。数据库查询、完整会话管理、业务接口和资源级授权仍然有各自的位置。把这些职责分清,Proxy 才会短、快,而且容易测试。
当前课程项目实际使用 Next.js 16.1.6 和 React 19.2.3。本章的可运行代码以这个版本为基线,同时会指出当前官方文档中已经更新、但 16.1.6 的包导出尚未同步改名的测试 API。
Next.js 16 将原来的 Middleware 文件约定重命名为 Proxy。功能主体延续下来,但新名称刻意强调“应用前方的网络边界”。这也提醒我们:不要把它当成一个可以随意塞入所有后端逻辑的万能钩子。
第一次接触 Proxy 时,最容易出现的误区是:既然它能在请求前运行,那就把登录校验、限流、日志、语言、实验分组、数据查询全放进去。
技术上能写,不等于职责放对了。我们先按问题类型选工具。
比如 /old-docs 永远跳到 /docs,直接写配置即可:
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
async redirects() {
return [
{
source: '/old-docs/:path*',
destination: '/docs/:path*',
permanent: true,
},
]
},
}
export default nextConfig只有当目的地取决于当前请求时,Proxy 才真正有价值。比如同一条 /workspace 请求,需要按照已经验证的租户域名重写到不同的内部路由,这个结果无法在构建时写死。
官方文档把 Proxy 定位为“没有更合适 API 时才使用”的工具。这个建议不是因为 Proxy 不稳定,而是因为它处在请求公共路径上。放进去的每一段慢逻辑,都会被所有匹配请求重复承担。
Proxy 不是请求进入 Next.js 后执行的第一段配置,也不是所有业务代码完成后的响应钩子。理解它的准确位置,可以解释很多“为什么没有命中”“为什么配置先跳走了”的问题。
Next.js 当前的路由处理顺序是:
先应用 next.config 中的 headers。这些静态规则可以在请求进入动态逻辑前补充响应头。
再检查 next.config 中的 redirects。如果固定重定向已经命中,请求不会继续寻找页面。
接着才运行符合 matcher 的 Proxy。Proxy 可以继续、重定向、重写,或者直接产生响应。
Proxy 放行后,Next.js 检查 beforeFiles rewrites,再匹配 、、、 等文件系统资源。
把它画成一条线,就是:
浏览器
↓
next.config headers
↓
next.config redirects
↓
Proxy
↓
beforeFiles rewrites
↓
文件系统路由与静态资源
↓
afterFiles rewrites
↓
动态路由
↓
fallback rewrites
假设 /workspace-old 永远迁移到 /workspace。如果把它写在 Proxy 中,那么每个可能命中的请求都要加载 Proxy 逻辑,再执行一次条件判断。配置式 redirect 表达得更直接,而且执行顺序还在 Proxy 之前。
Proxy 更适合这样的条件:
/workspace 临时跳到 /login。acme.example.com 和 north.example.com 映射到不同租户。board-b 时,把公开路径重写到新版页面。这些决策都依赖请求本身。
Server Action 或其他 Server Function 会以 POST 请求发往它所在的页面路由。它不是一条永远叫作 /api/action 的独立地址。
这带来一个容易遗漏的后果:如果你修改 matcher,让某个页面路径不再经过 Proxy,那么这个页面里的 Server Function 调用也会跳过 Proxy。
所以 Proxy 最多承担“提前把明显未登录用户送走”的体验职责。Server Action 必须把自己看成可以被直接请求的服务端入口,在函数内部重新验证会话和权限。
不要用“这个页面经过 Proxy”推导“这个页面里的 Server Action 一定安全”。matcher 会改,Action 会移动,攻击者也不会通过按钮来调用服务端函数。权威授权必须写在 Action、Route Handler 或它们共同调用的数据访问层。
Next.js 15 及更早版本通常使用 middleware.ts。Next.js 16 将这个文件约定标记为弃用,并改名为 proxy.ts。
这个改名并不是把浏览器代理服务器搬进项目。它主要纠正一个长期误解:Next.js Middleware 不等于 Express 中可以按任意层数注册的 middleware 链。一个 Next.js 项目只有一个入口文件,框架也希望这段逻辑保持克制。
官方提供了 codemod:
npx @next/codemod@latest middleware-to-proxy .它会处理常见改名:
自动迁移之后仍要人工检查。codemod 可以改名字,无法判断一段数据库查询是否应该从 Proxy 移走,也无法替你验证 matcher 是否遗漏了 Server Function 所在路径。
不使用 src 目录时,文件与 app 或 pages 同级:
project/
├── app/
├── proxy.ts
├── next.config.ts
└── package.json使用 src 时,放在 src 内:
project/
├── src/
│ ├── app/
│ └── proxy.ts
├── next.config.ts
└── package.json如果项目配置了自定义 pageExtensions,Proxy 文件扩展名也要遵循同一约定。
可以使用命名导出:
// proxy.ts
import { NextResponse, type NextRequest } from 'next/server'
export function proxy(request: NextRequest) {
return NextResponse.next()
}也可以使用默认导出:
// proxy.ts
import { NextResponse, type NextRequest } from 'next/server'
export default function proxy(request: NextRequest) {
return NextResponse.next()
}项目不能同时拥有多层 Proxy 文件,但逻辑可以拆成普通模块:
// app/lib/proxy/check-workspace-session.ts
import type { NextRequest } from 'next/server'
export function hasWorkspaceSessionHint(request: NextRequest) {
return Boolean(request.cookies.get('session')?.value)
}// proxy.ts
import { NextResponse, type NextRequest } from 'next/server'
import { hasWorkspaceSessionHint } from '@/app/lib/proxy/check-workspace-session'
export function proxy(request: NextRequest) {
if (!hasWorkspaceSessionHint(request)) {
return NextResponse.redirect(new URL('/login', request.url))
}
return NextResponse.next()
}
拆模块是为了让规则可读、可测,不是为了模拟十几层隐式中间件。入口函数应该让人一眼看出哪些条件会结束请求,哪些条件会继续。
Next.js 16 的 Proxy 使用 Node.js runtime。不要在 proxy.ts 中写:
// 错误示例:Proxy 不支持 runtime 配置
export const runtime = 'edge'在 Proxy 文件设置 runtime 会报错。少数仍只支持 Edge Runtime 的旧认证库可能要求继续使用已弃用的 Middleware 兼容路径,但长期方案应是升级库或重新评估集成方式,不能假设 proxy.ts 可以切回 Edge。
Next.js 默认会规范化部分 URL,并根据 trailingSlash 等配置处理尾斜杠。大多数项目应该保留默认行为,让直接访问和客户端导航得到一致地址。
两个高级配置只适合明确的迁移需求:
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
skipTrailingSlashRedirect: true,
skipProxyUrlNormalize: true,
}
export default nextConfigskipTrailingSlashRedirect 让应用自己决定哪些路径保留或移除尾斜杠,适合逐步迁移旧站。skipProxyUrlNormalize 让 Proxy 看到更接近原始请求的路径,例如旧式 /_next/data/... 形态。
不要为了“获得更多控制”默认打开它们。自定义规范化必须处理文件扩展名、.well-known、循环重定向、直接访问与客户端导航差异,并补齐测试。能用 trailingSlash、固定 redirect 或 rewrite 表达时,继续使用声明式配置。
如果不导出 matcher,Proxy 会收到项目里的所有请求,包括 _next/static、_next/image 和 public 目录资源。登录跳转写得稍有问题,CSS、JavaScript、图片甚至 favicon 都可能被送到登录页。
matcher 的第一项职责是缩小执行面。
保护工作区最直接的写法是:
export const config = {
matcher: ['/workspace/:path*', '/settings/:path*'],
}:path* 表示后面的路径段可以出现零次或多次,因此 /workspace、/workspace/projects 和 /workspace/projects/atlas 都会匹配。
常见修饰符包括:
source 必须从 / 开始,而且匹配从路径开头锚定。裸写 /about 只覆盖 /about 及它的可选尾斜杠;要覆盖 /about/team 等子路径,应写成 /about/:path*。两种写法都不会误匹配 /blog/about。
如果 Proxy 要覆盖大部分页面,可以反向排除内部资源:
export const config = {
matcher: [
'/((?!api|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
],
}这段规则表达的是“匹配除这些路径之外的内容”。它不是固定模板。若本章后面的 Proxy 需要集中处理 /api CORS,就不能又把 api 排除。matcher 必须和实际职责一起设计。
下面的代码看起来没有问题,构建器却无法可靠提取动态值:
const protectedRoot = process.env.PROTECTED_ROOT ?? '/workspace'
export const config = {
matcher: [protectedRoot],
}matcher 应直接写成构建时可识别的字面量:
export const config = {
matcher: ['/workspace/:path*'],
}环境差异可以在 Proxy 函数内部处理,但不能依赖动态变量生成 matcher。
matcher 对象可以同时检查 Header、Cookie、Query 和 Host:
export const config = {
matcher: [
{
source: '/workspace/:path*',
has: [
{
type: 'host',
value: 'acme.example.com',
},
{
type: 'query',
key: 'preview',
value: '1',
},
],
missing: [
{
type: 'cookie',
要命中这条规则,source 和所有 has 条件都要成立,而且不能有任何一个 missing 条件匹配。missing 没写 value 时表示该字段必须缺失;写了 value 时,字段不存在或存在但值不匹配,都能满足这一项。
另一类常见用法是排除客户端预取,避免把分析事件记成一次真实浏览:
export const analyticsConfig = {
matcher: [
{
source:
'/((?!api|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)',
missing: [
{ type: 'header', key: 'next-router-prefetch' },
{ type: 'header', key: 'purpose', value: 'prefetch' },
],
},
],
}这里的 missing 只适合控制日志、分析等非安全副作用。不要因为预取请求不算“真正访问”,就让它绕过数据授权。预取仍然可能触发路由数据请求。

matcher 决定 Proxy 是否执行,不决定用户是否有权读取数据。它可能因为下面这些原因变化:
/workspace 前缀。因此,matcher 可以减少不必要工作,也可以提前改善登录体验,但无法替代下游授权。
_next/data 的特殊情况当前官方文档特别说明:即使 negative matcher 写了排除 _next/data,Next.js 仍可能让 Proxy 对这些数据请求执行。这是有意的安全行为,用来避免页面受到保护、对应数据请求却漏掉检查。
我们不应依赖这个特殊行为代替授权。正确理解是:框架在尽量减少意外漏洞,而应用仍要在数据入口完成验证。
matcher 测试至少要覆盖真实页面、静态资源、预取请求、无 Cookie 请求和 Server Function POST。只在浏览器里手动打开一个页面,无法证明规则完整。
Proxy 函数收到的是 NextRequest。它扩展了 Web 标准 Request,增加了对 Next.js URL 和 Cookie 的便利访问。
import type { NextRequest } from 'next/server'
export function inspectRequest(request: NextRequest) {
return {
method: request.method,
href: request.url,
pathname: request.nextUrl.pathname,
search: request.nextUrl.search,
view: request.nextUrl.searchParams.get('view'),
session: request.cookies.get('session')?.value,
origin: request.headers.get('origin'),
}
}对贯穿案例的地址:
https://acme.example.com/workspace/projects/atlas?view=board关键字段是:
request.nextUrl 是带有 Next.js 扩展信息的 URL。构造重写目标时,先复制一份更容易审查:
import { NextResponse, type NextRequest } from 'next/server'
export function proxy(request: NextRequest) {
const target = request.nextUrl.clone()
target.pathname = '/internal/workspace'
return NextResponse.rewrite(target)
}clone 会保留原查询参数。上例仍带着 ?view=board。如果你主动新建一个只有 pathname 的 URL,查询参数是否保留就要自己决定。
URL 片段 #comments 不会随普通 HTTP 请求发到服务器,所以 Proxy 看不到它。不要试图在 Proxy 中保存或验证 fragment。
ip 与 geo 已经移除旧教程常见这样的代码:
// 旧代码,不适用于 Next.js 16
const ip = request.ip
const country = request.geo?.countryNextRequest.ip 和 NextRequest.geo 从 Next.js 15 起已经移除。若部署平台提供客户端网络信息,应使用该平台明确文档化的 API 或由可信反向代理写入、覆盖的头部。
直接读取客户端提交的 x-forwarded-for 并把它当作真实 IP 会造成伪造。更不能在拿不到 IP 时把所有请求都归入 unknown 限流桶,否则一个用户就可能耗尽所有人的额度。
Cookie 由浏览器发送,Header 也可以由脚本或自定义客户端构造。它们可以作为分流信号,进入权限结论之前必须验证。
比如实验 Cookie 只允许 a 或 b:
import type { NextRequest } from 'next/server'
type BoardVariant = 'a' | 'b'
export function readBoardVariant(
request: NextRequest
): BoardVariant | undefined {
const value = request.cookies.get('board-variant')?.value
if (value === 'a' || value === 'b') {
同样,客户端发来的 x-tenant-id 不能直接代表租户身份。租户应从已经验证的域名或会话关系中计算,再由 Proxy 覆盖这个内部头。
Proxy 读取请求之后,通常有四种处理方式。选择时先问:浏览器地址栏要不要改变?请求是否还需要进入 Next.js 路由?

没有分流需求时,返回 NextResponse.next():
import { NextResponse, type NextRequest } from 'next/server'
export function proxy(request: NextRequest) {
console.info('proxy path', request.nextUrl.pathname)
return NextResponse.next()
}这不是“直接返回一个空页面”,而是告诉 Next.js 继续后面的路由过程。
重定向让浏览器前往新地址,地址栏会改变:
import { NextResponse, type NextRequest } from 'next/server'
export function proxy(request: NextRequest) {
if (request.nextUrl.pathname === '/workspace-old') {
return NextResponse.redirect(new URL('/workspace', request.url))
}
return NextResponse.next()
}登录、首次补语言前缀和规范 URL 都可能使用重定向。固定迁移仍优先写在 next.config。
第 6 章已经讲过 Server Component、Route Handler、Server Action 中的 redirect() 与导航语义。本章只讨论请求进入路由前的 NextResponse.redirect(),不重复 Link、Router 和历史记录。
重写改变内部处理目标,浏览器仍显示原地址:
import { NextResponse, type NextRequest } from 'next/server'
export function proxy(request: NextRequest) {
if (request.cookies.get('board-variant')?.value === 'b') {
const target = request.nextUrl.clone()
target.pathname = '/__experiments/board-b'
return NextResponse.rewrite(target)
}
return NextResponse.
用户仍看到 /workspace/projects/atlas,服务器实际渲染 /__experiments/board-b。
Next.js 会为 NextResponse.rewrite() 自动传播 RSC 导航所需的内部 rewrite 信息。不要用普通 fetch() 随意模拟同一过程;手工代理可能丢失 RSC 头,导致首屏请求和客户端导航结果不同。
Proxy 也可以提前结束请求:
import { type NextRequest } from 'next/server'
export function proxy(request: NextRequest) {
if (request.headers.get('x-blocked-client') === '1') {
return Response.json(
{
error: 'request rejected',
},
{
status: 403,
}
)
}
适合的场景包括快速拒绝、维护状态和集中处理 CORS 预检。完整业务接口、上传和流式响应仍然由第 7 章的 Route Handler 承担。
重定向和重写都要防止循环。比如 /login 自己也在 matcher 中,而未登录分支没有排除 /login,浏览器就会不断重定向。
“Proxy 可以修改 headers”这句话信息不够。请求头和响应头方向相反,放错一层可能暴露凭据,也可能破坏 Server Action 或流式响应。

要让后续页面、Route Handler 或 Server Action 读到修改后的请求头,使用 request.headers 这一层:
import { NextResponse, type NextRequest } from 'next/server'
export function proxy(request: NextRequest) {
const requestHeaders = new Headers(request.headers)
requestHeaders.delete('x-tenant-id')
requestHeaders.set('x-tenant-id', 'acme')
return NextResponse.next({
request: {
headers: requestHeaders,
},
先删除客户端提交的 x-tenant-id,再写入服务端计算结果,可以防止用户伪造内部租户头。
这段克隆全部头部的写法只适合继续进入同一个 Next.js 应用。若要 rewrite 到外部服务,应构造更窄的 allowlist:
import type { NextRequest } from 'next/server'
const SAFE_EXTERNAL_HEADERS = [
'accept',
'accept-language',
'user-agent',
] as const
export function buildExternalHeaders(request: NextRequest) {
const forwarded = new Headers()
for (const name of SAFE_EXTERNAL_HEADERS) {
不要默认转发 cookie、authorization、客户端 x-* 头和平台内部头。外部服务是否需要某个凭据,必须逐项设计。
响应头写在返回的 Response 上:
import { NextResponse, type NextRequest } from 'next/server'
export function proxy(request: NextRequest) {
const response = NextResponse.next()
const requestId = crypto.randomUUID()
response.headers.set('x-request-id', requestId)
return response
}浏览器和中间缓存都可能看到这个响应头,所以里面不能放 Session、内部权限、数据库结果或用户隐私。
下面的调用并不是“把修改后的请求头传给页面”:
import { NextResponse, type NextRequest } from 'next/server'
export function proxy(request: NextRequest) {
const headers = new Headers(request.headers)
// 不要这样把整组请求头作为响应头发给客户端
return NextResponse.next({ headers })
}NextResponse.next({ headers }) 会把这些值作为响应头发送给客户端。复制整组请求头可能泄漏敏感信息;覆盖 Content-Type 等头还可能破坏 Server Action 提交和流式响应。
一些 Web 服务器和代理会对头部总大小设置上限。把完整 JSON、权限列表或缓存数据塞进 x-* 头,很容易触发 431 Request Header Fields Too Large。
头部适合短标识,例如请求 ID、已经验证的租户 ID、语言代码。业务对象仍然从数据层读取。
读取请求 Cookie:
const variant = request.cookies.get('board-variant')?.value如果希望浏览器以后继续发送实验分组,必须在响应上设置:
const response = NextResponse.next()
response.cookies.set({
name: 'board-variant',
value: 'b',
httpOnly: true,
sameSite: 'lax',
secure: true,
path: '/',
maxAge: 60 * 60 * 24 * 30,
})
return response只修改当前请求视图,不会自动让浏览器持久保存新 Cookie。
Proxy 最常见的用途之一是:用户没有登录提示时,提前从受保护页面跳到登录页。
这个体验很有价值。它仍然不是完整授权。

Proxy 可以快速读取签名 Session Cookie,判断它是否包含最低限度的用户标识:
// app/lib/session-hint.ts
import 'server-only'
export type SessionHint = {
userId: string
expiresAt: number
}
export async function readSessionHint(
value: string | undefined
): Promise<SessionHint | null> {
if (!value) {
return null
这里的 declare 只是把认证库应提供的函数签名写清楚,不是一份已经实现的验签逻辑。落地时要换成项目认证库的真实导入,并测试签名错误、过期和密钥轮换;绝不能把 Cookie 中的 JSON 直接解析后当成会话。
然后在 Proxy 中做乐观重定向:
import { NextResponse, type NextRequest } from 'next/server'
import { readSessionHint } from '@/app/lib/session-hint'
export async function proxy(request: NextRequest) {
const sessionValue = request.cookies.get('session')?.value
const session = await readSessionHint(sessionValue)
if (!session) {
const loginUrl =
这里没有查询“这个用户是否属于 Atlas 项目”。Proxy 只确认存在一个可快速验证的登录提示,并改善未登录访问体验。
上例的 next 来自当前站点请求路径,风险较低。但登录成功后消费查询参数时,仍应验证它是允许的同源路径,不能直接执行:
// 危险示例
redirect(searchParams.get('next') ?? '/workspace')可以把允许范围收窄到项目内路径:
const RETURN_ORIGIN = 'https://return.invalid'
function isWorkspacePath(pathname: string) {
return (
pathname === '/workspace' ||
pathname.startsWith('/workspace/') ||
pathname === '/settings' ||
pathname.startsWith('/settings/')
)
}
export function safeReturnPath(value
//evil.example 看起来以斜杠开头,URL 解析后却是外部域名,因此要同时检查协议解析结果和允许路径。
当前仓库根目录已经有一个很小的 proxy.ts:访问 /login 且没有 redirect 查询参数时,它读取 Referer;只有 Referer 与当前请求同源、并且来源不是登录页或首页,才把来源 pathname 写入 redirect。
这段代码有两个值得保留的判断:
redirect。它也有清楚的能力边界。Referer 可能因为浏览器策略而缺失,也可以由非浏览器客户端构造,所以它只能补充登录后的返回体验,不能证明用户身份。URL fragment 从未发送到服务器,代码也只保存 pathname;如果以后决定保留查询参数,要先确认其中没有一次性 Token 或其他敏感信息。
最重要的一点仍然是:登录完成后消费 redirect 时再调用 safeReturnPath。入口处做过同源检查,不代表这个查询参数以后永远只来自该入口。
真正读取项目数据时,再验证数据库会话和项目成员关系:
// app/lib/dal/projects.ts
import 'server-only'
import { cache } from 'react'
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
import { db } from '@/app/lib/db'
import { decryptSessionId } from '@/app/lib/session'
export const verifyProjectAccess = cache(async (slug: string) => {
const cookieStore =
页面、Route Handler 和 Server Action 都调用同一验证函数:
// app/workspace/projects/[slug]/actions.ts
'use server'
import { verifyProjectAccess } from '@/app/lib/dal/projects'
import { db } from '@/app/lib/db'
export async function renameProject(slug: string, name: string) {
const membership = await verifyProjectAccess(slug)
if (!membership || membership.role !== 'owner') {
throw
即使有人直接构造 Server Action 请求,或者 matcher 将来漏掉了这条路径,数据层仍然会拒绝没有权限的用户。
一套稳健的认证结构有两层:Proxy 负责快速、乐观的路由体验;DAL、Route Handler 和 Server Action 负责权威会话与资源授权。两层并存,不互相替代。
Proxy 的价值不只在登录。只要路由目标取决于当前请求,它都可能参与。不过每种分流都要把输入范围收紧。

Accept-Language 可能包含多个语言、地区和权重:
zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7用 header.includes('en') 判断语言会忽略权重,也可能误判相似代码。实际项目应使用符合语言协商规则的解析库,再从应用支持的 locale 中选择。
下面的例子使用两个外部包,先安装依赖和 Negotiator 的类型:
npm install negotiator @formatjs/intl-localematcher
npm install --save-dev @types/negotiatorimport Negotiator from 'negotiator'
import { match } from '@formatjs/intl-localematcher'
import { NextResponse, type NextRequest } from 'next/server'
const locales = ['zh-CN', 'en-US'] as const
const defaultLocale = 'zh-CN'
function detectLocale(request: NextRequest) {
const headers = Object.fromEntries(request.headers)
先判断路径是否已经带 locale,可以避免 /zh-CN/zh-CN/... 循环。语言只是路由和展示偏好,不能拿来做地区合规或权限结论。
多租户应用常把不同客户放在子域名或自定义域名上。不要把 Host 原样拼进内部路径:
// 危险思路:未经验证的 hostname 进入内部路由
target.pathname = '/__tenants/' + request.nextUrl.hostname + pathname改成明确映射:
import { NextResponse, type NextRequest } from 'next/server'
const TENANT_BY_HOST: Readonly<Record<string, string>> = Object.freeze({
'acme.example.com': 'acme',
'north.example.com': 'north',
})
export function proxy(request: NextRequest) {
const tenantId =
这里使用 request.nextUrl.hostname,不带端口。未知域名直接结束,不回退到第一个租户。外层反向代理还要保证客户端不能通过伪造转发头改变框架看到的主机名。
内部 x-tenant-id 可以帮助后续代码定位租户,但它仍然不能单独证明用户属于该租户。数据查询还要把租户、用户和资源关系一起验证。
如果每个请求都调用一次 Math.random(),同一用户刷新页面就可能在 A、B 两版之间来回切换。第一次分组后,应把合法枚举写进 Cookie:
import { NextResponse, type NextRequest } from 'next/server'
type Variant = 'a' | 'b'
function chooseVariant(request: NextRequest): {
variant: Variant
isNew: boolean
} {
const current = request.cookies.get('board-variant')?.value
if (current === 'a'
实验 Cookie 不是权限凭据。用户可以修改它,所以内部目标只能从 a、b 两个已知值中选择。
还要考虑缓存。若不同 Header 或 Cookie 会产生不同内容,却被 CDN 按同一公开 URL 共享缓存,用户可能拿到错误变体。一个更清楚的做法是把变体写进内部 rewrite pathname,并确认部署平台最终使用的缓存键和 Cache-Control 行为。
如果 /blog 永远由另一个 Zone 处理,优先使用 next.config rewrite。只有特性开关、租户或迁移状态需要在请求时决定目标,才使用 Proxy。
这也减少跨 Zone 导航的额外延迟。第 14 章会从部署角度继续讨论 Zone、CDN 和外层路由,本章只负责 Next.js 应用内部的动态请求决策。
第 7 章已经负责 Route Handler 的请求方法、响应体和单接口 CORS。本章只补一个边界:当一整组 /api/:path* 共享同一来源策略时,Proxy 可以集中处理预检与响应头。
import { NextResponse, type NextRequest } from 'next/server'
const ALLOWED_ORIGINS = new Set([
'https://dashboard.example.com',
'https://admin.example.com',
])
const CORS_BASE_HEADERS = {
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
}
function appendCorsHeaders(response: NextResponse,
这里没有把请求的 Origin 原样反射,而是先查允许集合。由于响应会随 Origin 改变,返回明确来源时要带 Vary: Origin。携带 Cookie 或 Authorization 的跨域请求不能把 Access-Control-Allow-Origin 写成 *。
还要记住:CORS 控制的是浏览器脚本能否读取跨源响应。curl、服务端脚本和攻击者自己的客户端不受浏览器 CORS 限制,所以 CORS 不等于认证、授权或 CSRF 防护。
若每个接口的来源、方法和凭据策略不同,把规则留在各自 Route Handler 更清楚,不必为了“统一”强行塞进 Proxy。
Proxy 可以改变地址和上游请求,所以一个很小的输入错误,也可能扩大成开放重定向、内部服务访问或跨租户泄漏。
危险代码通常长这样:
const next = request.nextUrl.searchParams.get('next')
if (next) {
return NextResponse.redirect(new URL(next))
}攻击者可以构造一个看似来自可信站点的登录链接,最后把用户送到钓鱼域名。
更稳妥的选择依次是:
下面的写法让客户端决定服务器要访问哪里:
const upstream = request.nextUrl.searchParams.get('upstream')
if (upstream) {
return NextResponse.rewrite(new URL(upstream))
}攻击者可能把目标改为内网服务、云环境元数据地址或本机端口。安全做法是让客户端只提交一个受限键:
const UPSTREAMS = {
docs: 'https://docs.example.com',
status: 'https://status.example.com',
} as const
type UpstreamKey = keyof typeof UPSTREAMS
function isUpstreamKey(value: string): value is UpstreamKey {
return value === 'docs' || value === 'status'
}
服务端映射固定协议和域名,比尝试维护“危险 IP 黑名单”更可靠。
Host 常用于租户、自定义域名和绝对链接生成。如果应用盲目信任 Host 或 X-Forwarded-Host,攻击者可能影响重定向目标、密码重置链接、租户选择和缓存内容。
防护需要应用与基础设施配合:
把公开 URL rewrite 到 /__tenants/acme/...,不会让这个内部路径自动拥有权限。攻击者仍可能尝试直接请求它,matcher 也可能因为路由重构漏掉。
可以把内部前缀纳入 matcher,并对外部直接请求返回 404;或者在独立的外层路由中确保该前缀根本不是公开入口。无论用哪种方式,数据层仍要验证租户和用户关系:
import { NextResponse, type NextRequest } from 'next/server'
export function proxy(request: NextRequest) {
if (request.nextUrl.pathname.startsWith('/__tenants/')) {
return new NextResponse('Not Found', {
status: 404,
})
}
return NextResponse.next()
}这道拦截改善外部行为,但不是唯一防线。
如果响应由 Cookie、Host、Origin 或自定义 Header 决定,而共享缓存忽略这些输入,同一个缓存键就可能保存错误内容。
审查时问三个问题:
不要随意覆盖 Next.js 生成的 Cache-Control。第 13 章会讲应用级性能与缓存,第 14 章会讲 CDN 和部署;本章只要求路由分流不能悄悄破坏缓存隔离。
结构化日志比拼接字符串更容易查询,但仍不能记录:
记录请求 ID、规范化路径、租户 ID、决策类型和有限错误码通常已经足够。
Proxy 位于 Route Handler 之前,所以读请求体时会遇到一个实际问题:Proxy 读完以后,下游还能不能再读?
Next.js 在存在 Proxy 时会克隆并缓冲请求体,让 Proxy 和下游路由都能读取。默认每个请求最多缓冲 10 MB。
请求体超过限制时,Next.js 16.1.6 当前的行为是:
这意味着不能把默认限制当作可靠的上传拦截器,也不能在 Proxy 对可能截断的请求体做签名验证后得出“内容安全”的结论。
实验配置可以调整上限:
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
experimental: {
proxyClientMaxBodySize: '1mb',
},
}
export default nextConfigproxyClientMaxBodySize 仍是实验配置。大型上传、Webhook 签名、JSON Schema 验证和完整请求体解析,优先放在 Route Handler,并由外层网关设置明确的请求大小限制。
第 7 章负责 request.json()、formData()、ReadableStream 和响应体;这里不重复接口实现,只解释 Proxy 为什么不适合承担这些工作。
Proxy 的第二个参数是 NextFetchEvent。waitUntil() 接收一个 Promise,延长当前 Proxy 的生命周期,适合发送脱敏分析或路由决策日志:
import {
NextResponse,
type NextFetchEvent,
type NextRequest,
} from 'next/server'
async function recordRoutingDecision(input: {
requestId: string
pathname: string
decision: string
}) {
await fetch('https://telemetry.example.com/events', {
method: 'POST',
headers: {
'content-type':
客户端不用等待日志请求完成,但 waitUntil 也不是任务队列。支付确认、权限变更、审计合规写入等必须成功的工作,需要可重试、可观测的持久化机制,不能只依赖一次后台 Promise。
Proxy 的性能优化并不复杂:缩小 matcher,保持逻辑短,把不属于它的工作移走。

旧示例常用模块级 Map 做限流:
// 不要作为生产限流方案
const rateLimitMap = new Map<
string,
{
count: number
resetAt: number
}
>()这段状态只存在于当前进程。多实例部署会各自计数,实例重启会清空,按需扩缩容也会改变结果。框架还明确提醒 Proxy 可能独立于主要渲染代码部署,不能依赖共享模块或 globals 传递状态。
需要分布式限流时,使用平台网关、WAF 或支持原子更新的共享存储。更重要的是,先确认用于限流的用户、API Key 或客户端地址来自可信来源。
固定配置对象可以放在模块级,因为它不依赖请求间变更;可变计数、缓存和会话状态不应该。
在 Proxy 中使用下面这些 fetch 选项没有效果:
cachenext.revalidatenext.tags不要把 Proxy 包装成页面数据缓存。页面数据缓存、标签失效和增量重验证由第 13 章处理。
Proxy 会覆盖每个匹配请求,包括客户端预取。一次 50 ms 数据库检查看似很短,放到共享布局的所有预取请求上就会不断叠加。
官方认证建议只读取 Cookie 做乐观检查,把数据库会话验证放在 DAL。多租户域名映射若必须从外部系统读取,也应评估平台提供的低延迟配置存储,而不是每次查询主数据库。
下面的 duration 只计算 Proxy 自己执行到返回的时间:
const startedAt = performance.now()
const response = NextResponse.next()
const duration = performance.now() - startedAt
response.headers.set(
'server-timing',
'proxy;dur=' + duration.toFixed(1)
)页面渲染、Route Handler、数据库和流式传输都还没有完成。不要把它命名为 x-response-time 并当作整条请求性能。
Proxy 的平台支持边界是:
Next.js 16 的最低 Node.js 要求是 20.9。Proxy 本身使用 Node runtime,但函数时长、内存、区域、日志保留和 waitUntil 的承载方式仍由部署平台决定。
不要假设本地的这些现象必然复制到生产:
Host 与 X-Forwarded-Host 的来源相同。这里的 proxy.ts 是 Next.js 框架内的请求边界。nginx、CDN、WAF、负载均衡器和 API Gateway 位于应用外层。
外层设施更适合:
proxy.ts 更适合:
第 14 章会继续讲构建产物、Node/Docker、CDN、Adapter 和反向代理配置。本章不把基础设施配置混进 Proxy 代码。
Proxy 的输入和输出都很明确,非常适合单元测试。最值得先测的不是“函数能否返回一个 Response”,而是 matcher 是否覆盖正确路径,以及 redirect/rewrite 的真实目的地。
当前官方文档已经使用 unstable_doesProxyMatch。但本课程安装的 Next.js 16.1.6 在 next/experimental/testing/server 中仍然导出旧名称 unstable_doesMiddlewareMatch。
为了让当前项目代码可以编译,可以在 import 时改成本章语义名称:
import {
unstable_doesMiddlewareMatch as doesProxyMatch,
getRedirectUrl,
getRewrittenUrl,
isRewrite,
} from 'next/experimental/testing/server'升级 Next.js 后先检查实际包导出和类型,再决定是否改成:
import {
unstable_doesProxyMatch as doesProxyMatch,
} from 'next/experimental/testing/server'测试工具仍带 unstable_ 前缀,名称可能继续调整。课程正文以项目锁定的 16.1.6 为可运行基线,不能因为网站文档已改名,就给当前依赖写一个不存在的 import。
以下以 Vitest 语法展示测试结构。项目若还没有测试运行器,先安装:
npm install --save-dev vitest同时要让 Vitest 的解析配置与 tsconfig.json 保持一致,确保 @/ 别名在测试环境中也能找到项目根目录。这组 matcher 断言可以单独放在 proxy.matcher.test.ts 中:
import { describe, expect, it } from 'vitest'
import {
unstable_doesMiddlewareMatch as doesProxyMatch,
} from 'next/experimental/testing/server'
import { config } from './proxy'
describe('proxy matcher', () => {
it('覆盖工作区页面', () => {
expect(
doesProxyMatch({
config,
nextConfig: {},
url: 'https://acme.example.com/workspace/projects/atlas',
最后一个测试在本章的最终 config 中应该为 true,因为它只按路径匹配,没有排除预取。认证 matcher 通常不应仅因为预取而排除请求;只负责分析的 matcher 可以排除。
16.1.6 的 doesProxyMatch 参数可以模拟 URL、Header 和 Cookie,但没有 method 字段。它能证明 Server Function 所在页面的路径是否被 matcher 覆盖,不能单独证明真实 POST 链路没有绕过 Proxy。这一点要用集成测试发起真实 Action 请求,并另外测试即使 Proxy 不执行,DAL 仍会拒绝越权。
本章最终的 proxy() 有第二个 NextFetchEvent 参数,还会调用会话和可观测模块。下面两个代码片段与这段准备代码属于同一个 proxy.test.ts 文件:
import { describe, expect, it, vi } from 'vitest'
import {
NextRequest,
type NextFetchEvent,
} from 'next/server'
import {
getRedirectUrl,
getRewrittenUrl,
isRewrite,
} from 'next/experimental/testing/server'
import { proxy } from './proxy'
vi.mock('@/app/lib/session-hint', () => ({
readSessionHint: vi.fn(
async (value
describe('tenant rewrite', () => {
it('把已登记域名重写到内部租户路径', async () => {
const request = new NextRequest(
'https://acme.example.com/workspace/projects/atlas?view=board',
{
headers: {
cookie: 'session=signed-session-hint',
},
}
)
const response = await proxy(request, createProxyEvent())
这个测试同时确认查询参数没有在 rewrite 时丢失。
describe('login redirect', () => {
it('无会话时只生成同源登录地址', async () => {
const request = new NextRequest(
'https://acme.example.com/workspace/projects/atlas?view=board'
)
const response = await proxy(request, createProxyEvent())
const redirectUrl = getRedirectUrl(response)
expect(redirectUrl).toBe(
再把本章刚收紧的 Host 和内部路径边界锁进回归测试:
describe('request boundary', () => {
it('未知 Host 即使没有会话也直接返回 404', async () => {
const request = new NextRequest(
'https://unknown.example.com/workspace'
)
const response = await proxy(request, createProxyEvent())
expect(response.status).toBe(404)
expect(response.headers.get(
其他安全测试还应覆盖:
next=https://evil.example 回退到安全默认路径。next=//evil.example 被拒绝。x-tenant-id 会被覆盖。测试可以证明规则的输入输出,浏览器和命令行则用来观察真实链路。
发生 redirect 时:
Location。发生 rewrite 时:
getRewrittenUrl 比猜测响应头更可靠。设置响应 Cookie 时,检查 Set-Cookie 的 Secure、HttpOnly、SameSite、Path 和生命周期。
检查未登录 redirect:
curl -sv \
'http://localhost:3000/workspace/projects/atlas?view=board' \
-o /dev/null先不要加 -L,否则 curl 会自动跟随跳转,你会错过第一跳的状态码和 Location。
带 Cookie 检查:
curl -sv \
-H 'Cookie: session=signed-session-hint' \
'http://localhost:3000/workspace/projects/atlas?view=board' \
-o /dev/null检查预取条件:
curl -sv \
-H 'Purpose: prefetch' \
-H 'Next-Router-Prefetch: 1' \
'http://localhost:3000/workspace' \
-o /dev/nullcurl -I 会发 HEAD,请求方法和真实 GET/POST 不同。它适合快速看响应头,不能替代方法相关的完整测试。
推荐字段:
requestId
normalizedPath
tenantId
decision = next | redirect | rewrite | response
proxyDurationMs
resultCode不要把 Proxy 耗时叫作总响应时间,也不要记录完整 Session、Authorization 或请求体。
本地开发服务器通常直接收到浏览器请求,生产环境前面可能还有 CDN、负载均衡器和反向代理。
上线后需要确认:
X-Forwarded-*。waitUntil 失败在哪里记录。这些差异不能靠本地 console.log 推测,要根据部署平台文档和真实请求验证。
固定 redirect、rewrite 和 header 用 next.config 更直接。Proxy 留给必须读取请求才能决定的分支。
构建器无法静态分析动态 matcher,规则可能被忽略。把路径字面量写进导出的 config。
静态文件和图片优化请求也会执行 Proxy,错误 redirect 可能让整个页面失去样式。
Proxy 会覆盖预取和大量公共请求。这里只做快速 Cookie 乐观检查,权威验证放进 DAL。
它无法跨实例协调,重启会清空。使用外层网关或分布式原子存储。
request.ip这个属性已经移除。平台网络信息要按平台文档读取,并确认转发头由可信代理覆盖。
x-tenant-id客户端可以伪造。先删除,再从已验证 Host 或会话关系重建。
可能泄漏 Cookie、Authorization 和平台内部头。只转发明确需要的 allowlist。
会产生开放重定向或 SSRF。使用短键到固定目标的服务端映射。
matcher 可以改变,内部入口也可以被直接请求。页面、Route Handler 和 Server Action 都要在数据层验证。
预取是 Next.js 导航的一部分。日志可以不计预取,授权不能依赖“这不是正式点击”。
会泄漏数据、增加传输并触发 431。头部只传短、明确的标识。
Next.js 16 Proxy 使用 Node runtime,runtime 配置在这里会报错。
此时页面和接口还没执行。字段应明确叫 proxyDuration。
请求体可能只缓冲部分,实验大小配置也不会自动拒绝超限请求。上传和签名校验留给 Route Handler 与外层网关。
现在把本章的核心规则收束到一个精简版本。它只做五件事:
waitUntil 写一条不影响响应的脱敏路由日志。// proxy.ts
import {
NextResponse,
type NextFetchEvent,
type NextRequest,
} from 'next/server'
import { readSessionHint } from '@/app/lib/session-hint'
import { recordRoutingDecision } from '@/app/lib/observability'
const TENANT_BY_HOST: Readonly<Record<string, string>> = Object.freeze({
'acme.example.com': 'acme',
'north.example.com'
这份 Proxy 没有做的事情同样重要:
域名允许列必须在登录跳转之前检查。否则,一个未登录的未知 Host 会先用未受信的请求主机名生成 Location,而不是按预期返回 404。最终 matcher 也主动包含 /__tenants/:path*,这样外部直访内部前缀时,Proxy 才真的有机会拒绝它。
真正读取项目时,页面和 Server Action 继续调用 verifyProjectAccess(slug)。即使 Proxy 被误配,数据也不会因此自动暴露。
proxy.ts 与 app 同级,只有一个入口导出。/login 自身。x-tenant-id 会被删除并重建。Vary: Origin。request.ip、可变全局 Map 或慢数据库查询。waitUntil 只承载非关键任务,而且捕获失败。下面是一段待审查代码:
export function proxy(request: NextRequest) {
const target = request.nextUrl.searchParams.get('target')
const tenant = request.headers.get('x-tenant-id')
if (target && tenant) {
return NextResponse.rewrite(
new URL(target),
{
request: {
headers: request.headers,
},
请先自己找出风险,再展开参考答案。
最后,用四个问题检查一段 Proxy:
答案都清楚以后,proxy.ts 就不再是一块容易膨胀的“请求杂物间”。它会保持成一段短而明确的路由决策代码:快速读取请求,做最少的判断,把真正的业务和授权交给更合适的层。
public_next/staticpagesapp如果还没有得到结果,框架继续检查 afterFiles rewrites、动态路由,最后才是 fallback rewrites。