引言:中间件——你的应用的第一道防线
如果你已经读过我们之前关于 Next.js App Router 认证和 Server Actions 的文章,你大概已经注意到一件事:在现代 Next.js 应用中,请求在到达你的页面组件之前,会经过好几层处理。而中间件(Middleware),就是这个链条中最靠前、也最关键的一环。
坦白说,中间件在 Next.js 生态里一直挺"尴尬"的。它的确很强大——你可以在请求级别做认证检查、地理位置路由、A/B 测试、国际化重定向……但与此同时,它也可能是整个框架里最容易被误用的功能。太多开发者一看到"中间件"三个字,就下意识地把它当成 Express.js 的 middleware 来用,往里面塞数据库查询、复杂的业务逻辑,结果反而拖慢了整个应用。(我见过有人在中间件里做全文搜索的,真的。)
更要命的是,2025 年 3 月爆出的 CVE-2025-29927 漏洞,让所有人彻底清醒了:如果你只靠中间件来做认证授权,你的应用可能比你想象的要脆弱得多。而到了 2026 年初,Next.js 16 更是直接把 middleware.ts 重命名为 proxy.ts——这可不仅仅是换了个名字,而是一次架构理念的重大转向。
这篇文章会从底层机制入手,带你彻底搞懂 Next.js 中间件的工作原理、最佳实践、安全陷阱,以及面向未来的迁移路径。不管你是刚开始用中间件的新手,还是已经在生产环境里深度依赖它的老手,这篇指南都能给你一些实实在在的东西。
一、中间件的核心机制:它到底在做什么?
1.1 请求生命周期中的位置
在 Next.js 中,当一个 HTTP 请求到来时,处理顺序大致是这样的:
- 中间件(Middleware) — 最先执行,在任何路由匹配之前
- 路由匹配 — 确定应该渲染哪个页面/布局
- 数据获取 — 服务器组件获取数据
- 渲染 — 生成 HTML 响应
换句话说,中间件有能力在请求碰到你的应用逻辑之前,就对它进行拦截、修改甚至直接响应。你可以把它想象成一个守在大门口的保安——检查来访者的身份、把人引导到正确的入口,甚至直接拒绝某些人入场。
1.2 运行环境:Edge Runtime
中间件默认跑在 Edge Runtime 上,不是标准的 Node.js 运行时。这个区别非常关键:
- Edge Runtime 基于 Web API(Request、Response),能部署在全球分布的边缘节点上
- 它没有 Node.js 内置模块的访问权限(比如
fs、path、crypto的部分 API) - 它是无状态的——请求之间不能保持状态
- 它不能读取请求体(只能读 headers、URL、cookies)
- 但它启动速度极快,延迟极低
这就是为什么你不能在中间件里做数据库查询或者读写文件——它压根没这个能力。如果你试图 import 一个用了 Node.js API 的库,构建的时候就会直接报错,没得商量。
不过好消息是,从 Next.js 15.5 开始,你可以选择让中间件运行在 Node.js 运行时上:
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export const config = {
runtime: 'nodejs', // 使用 Node.js 运行时
};
export function middleware(request: NextRequest) {
// 现在可以使用 Node.js API 了
return NextResponse.next();
}
但请注意:切换到 Node.js 运行时就意味着你放弃了边缘部署的优势——会带来更高的延迟和更慢的冷启动。除非你确实需要 Node.js 特有的功能,否则还是建议保持默认的 Edge Runtime。
1.3 文件结构与基本用法
创建中间件很简单——在项目根目录(或 src 目录下)放一个 middleware.ts 文件就行了:
// middleware.ts(项目根目录)
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
// 获取请求信息
const { pathname } = request.nextUrl;
const token = request.cookies.get('session-token');
// 未登录用户访问受保护路由时重定向
if (pathname.startsWith('/dashboard') && !token) {
return NextResponse.redirect(new URL('/login', request.url));
}
// 继续处理请求
return NextResponse.next();
}
// 配置匹配规则
export const config = {
matcher: ['/dashboard/:path*', '/api/:path*'],
};
有一条关键规则:每个项目只能有一个中间件文件。你没法在不同路由目录下放多个 middleware.ts。但你可以(而且应该)把逻辑拆分到不同的模块里,然后在主中间件文件中统一导入和组合——后面会详细聊这个。
二、Matcher 配置:精确控制中间件的作用范围
2.1 为什么 Matcher 至关重要
默认情况下,中间件会在每一个请求上运行。对,每一个——包括静态资源、图片、favicon,统统不放过。这显然不是我们想要的结果。不加 matcher 的中间件会无谓地增加每个请求的延迟,甚至可能让静态资源加载出问题。
2.2 常见 Matcher 模式
// 1. 单路径匹配
export const config = {
matcher: '/dashboard',
};
// 2. 通配符匹配(包含子路径)
export const config = {
matcher: '/dashboard/:path*',
};
// 3. 多路径匹配
export const config = {
matcher: ['/dashboard/:path*', '/admin/:path*', '/api/:path*'],
};
// 4. 排除静态资源(推荐的通用模式)
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)',
],
};
// 5. 组合:排除静态资源 + 只匹配特定路径
export const config = {
matcher: [
'/dashboard/:path*',
'/api/((?!public).*)', // api 路由,但排除 /api/public
],
};
2.3 运行时条件判断
除了静态的 matcher 配置,你还可以在中间件函数内部做动态判断:
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// 跳过公开路径
const publicPaths = ['/login', '/register', '/about'];
if (publicPaths.some(path => pathname.startsWith(path))) {
return NextResponse.next();
}
// 对 API 路由和页面路由使用不同的处理逻辑
if (pathname.startsWith('/api/')) {
return handleApiMiddleware(request);
}
return handlePageMiddleware(request);
}
实际项目中,这两种方式通常配合使用:matcher 负责粗粒度过滤(排除静态资源之类的),运行时条件负责细粒度的业务逻辑判断。各司其职,配合得很好。
三、核心能力:中间件到底能干什么
3.1 重定向(Redirect)
这应该是最常见的用法了——基于条件把用户重定向到不同的页面:
export function middleware(request: NextRequest) {
const token = request.cookies.get('auth-token')?.value;
const { pathname } = request.nextUrl;
// 已登录用户访问登录页,重定向到仪表盘
if (pathname === '/login' && token) {
return NextResponse.redirect(new URL('/dashboard', request.url));
}
// 未登录用户访问受保护路由,重定向到登录页
if (pathname.startsWith('/dashboard') && !token) {
const loginUrl = new URL('/login', request.url);
loginUrl.searchParams.set('callbackUrl', pathname);
return NextResponse.redirect(loginUrl);
}
return NextResponse.next();
}
小心无限重定向!说真的,这是中间件里最经典的 bug 了。一定要确保你的重定向逻辑不会形成死循环——未登录就跳 /login,到了 /login 中间件又检测到什么条件又跳走……永远到不了目的地。解决方法很简单:重定向之前先检查当前路径是不是就是目标路径。
3.2 重写(Rewrite)
重写和重定向不太一样:重定向会改变浏览器地址栏的 URL,但重写是在服务器端悄悄地把请求路由到另一个路径,用户看到的 URL 保持不变。这对多租户架构特别好用。
export function middleware(request: NextRequest) {
const hostname = request.headers.get('host') || '';
// 多租户架构:基于子域名重写到不同的路径
if (hostname.startsWith('blog.')) {
return NextResponse.rewrite(
new URL(`/sites/blog${request.nextUrl.pathname}`, request.url)
);
}
if (hostname.startsWith('docs.')) {
return NextResponse.rewrite(
new URL(`/sites/docs${request.nextUrl.pathname}`, request.url)
);
}
return NextResponse.next();
}
3.3 修改请求和响应头
export function middleware(request: NextRequest) {
// 添加请求头(传递给下游服务器组件)
const requestHeaders = new Headers(request.headers);
requestHeaders.set('x-request-id', crypto.randomUUID());
requestHeaders.set('x-user-country', request.geo?.country || 'unknown');
const response = NextResponse.next({
request: {
headers: requestHeaders,
},
});
// 添加安全相关的响应头
response.headers.set('X-Frame-Options', 'DENY');
response.headers.set('X-Content-Type-Options', 'nosniff');
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
response.headers.set(
'Content-Security-Policy',
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline';"
);
return response;
}
通过请求头向下游传递数据,是中间件跟服务器组件"对话"的主要方式。你可以在服务器组件中通过 headers() 函数读取这些自定义头——还挺方便的。
3.4 直接响应
有时候你可能想在中间件里直接返回响应,完全绕过后面的路由处理:
export function middleware(request: NextRequest) {
// 简单的 IP 黑名单
const blockedIPs = ['192.168.1.100', '10.0.0.50'];
const clientIP = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim();
if (clientIP && blockedIPs.includes(clientIP)) {
return new NextResponse('Forbidden', { status: 403 });
}
// 简单的速率限制检查(基于 cookie 的简易实现)
const rateLimitCookie = request.cookies.get('rate-limit-count');
const count = parseInt(rateLimitCookie?.value || '0', 10);
if (count > 100) {
return NextResponse.json(
{ error: 'Too many requests' },
{ status: 429 }
);
}
return NextResponse.next();
}
四、实战模式:中间件的高级用法
4.1 模块化中间件架构
随着应用规模变大,把所有逻辑塞到一个函数里迟早会变成一团乱麻。我个人的建议是尽早采用模块化的方式来组织中间件——别等到代码已经失控了才重构。
// lib/middleware/auth.ts
import { NextRequest, NextResponse } from 'next/server';
export function authMiddleware(request: NextRequest): NextResponse | null {
const token = request.cookies.get('session-token')?.value;
const protectedPaths = ['/dashboard', '/settings', '/profile'];
const isProtected = protectedPaths.some(path =>
request.nextUrl.pathname.startsWith(path)
);
if (isProtected && !token) {
const loginUrl = new URL('/login', request.url);
loginUrl.searchParams.set('callbackUrl', request.nextUrl.pathname);
return NextResponse.redirect(loginUrl);
}
return null; // 表示不需要干预,继续下一个中间件
}
// lib/middleware/i18n.ts
import { NextRequest, NextResponse } from 'next/server';
const SUPPORTED_LOCALES = ['zh', 'en', 'ja', 'ko'];
const DEFAULT_LOCALE = 'zh';
export function i18nMiddleware(request: NextRequest): NextResponse | null {
const { pathname } = request.nextUrl;
// 检查路径是否已包含语言前缀
const pathnameHasLocale = SUPPORTED_LOCALES.some(
locale => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
);
if (pathnameHasLocale) return null;
// 从 Accept-Language 头检测用户语言偏好
const acceptLanguage = request.headers.get('accept-language') || '';
const preferredLocale = SUPPORTED_LOCALES.find(locale =>
acceptLanguage.toLowerCase().includes(locale)
) || DEFAULT_LOCALE;
return NextResponse.redirect(
new URL(`/${preferredLocale}${pathname}`, request.url)
);
}
// middleware.ts(主文件)
import { NextRequest, NextResponse } from 'next/server';
import { authMiddleware } from './lib/middleware/auth';
import { i18nMiddleware } from './lib/middleware/i18n';
export function middleware(request: NextRequest) {
// 按优先级依次执行中间件
const authResult = authMiddleware(request);
if (authResult) return authResult;
const i18nResult = i18nMiddleware(request);
if (i18nResult) return i18nResult;
return NextResponse.next();
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico|.*\\..*).*)' ],
};
这种链式调用的模式好处很明显:每个模块只管一个关注点,既好测试又好维护。某个模块返回 null 就表示"我这边没啥要处理的",自动轮到下一个。简单优雅。
4.2 基于地理位置的路由
export function middleware(request: NextRequest) {
const country = request.geo?.country || 'US';
const { pathname } = request.nextUrl;
// 根据地区重写到本地化版本
const countryToLocale: Record<string, string> = {
CN: 'zh',
JP: 'ja',
KR: 'ko',
TW: 'zh-tw',
};
const locale = countryToLocale[country];
if (locale && !pathname.startsWith(`/${locale}`)) {
return NextResponse.rewrite(
new URL(`/${locale}${pathname}`, request.url)
);
}
return NextResponse.next();
}
这里有个坑要提一下:request.geo 在本地开发环境中基本上永远是 undefined,它依赖部署平台(像 Vercel)提供的地理信息。开发的时候记得手动设个 fallback 值来模拟,不然你会一直纳闷为什么地理路由没生效。
4.3 A/B 测试
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// 只对特定页面进行 A/B 测试
if (pathname !== '/pricing') {
return NextResponse.next();
}
// 检查是否已分配变体
const existingVariant = request.cookies.get('pricing-variant')?.value;
if (existingVariant) {
// 用户已有分组,保持一致性
return NextResponse.rewrite(
new URL(`/pricing/${existingVariant}`, request.url)
);
}
// 新用户随机分配变体
const variant = Math.random() < 0.5 ? 'control' : 'experiment';
const response = NextResponse.rewrite(
new URL(`/pricing/${variant}`, request.url)
);
// 设置 cookie 保持用户在同一变体中
response.cookies.set('pricing-variant', variant, {
maxAge: 60 * 60 * 24 * 30, // 30 天
httpOnly: true,
sameSite: 'lax',
});
return response;
}
4.4 基于 Edge Config 的动态重定向
如果你在 Vercel 上部署,还可以利用 Edge Config 搞一套不用重新部署就能管理的重定向规则,运营同学应该会很喜欢这个:
import { get } from '@vercel/edge-config';
import { NextRequest, NextResponse } from 'next/server';
interface RedirectRule {
source: string;
destination: string;
permanent: boolean;
}
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// 从 Edge Config 获取重定向规则
const redirects = await get<RedirectRule[]>('redirects');
if (redirects) {
const match = redirects.find(r => r.source === pathname);
if (match) {
return NextResponse.redirect(
new URL(match.destination, request.url),
match.permanent ? 308 : 307
);
}
}
return NextResponse.next();
}
五、安全警钟:CVE-2025-29927 深度解析
5.1 这个漏洞到底是怎么回事
2025 年 3 月 21 日,安全研究员 Rachid Allam 公开披露了 CVE-2025-29927——一个 CVSS 评分高达 9.1 的严重漏洞。
它的杀伤力有多大?攻击者只需要设置一个特殊的 HTTP 请求头,就能完全绕过 Next.js 中间件的所有检查。没错,是所有。
让我解释一下原理。Next.js 内部用了一个叫 x-middleware-subrequest 的请求头来标识"这是中间件自己发起的内部子请求"。本意是防止中间件陷入无限递归循环——当这个头的值中出现中间件名称的次数达到 MAX_RECURSION_DEPTH 时,Next.js 就会调用 NextResponse.next(),直接跳过所有中间件逻辑。
问题出在哪?这个头没有被正确地从外部请求中剥离掉。也就是说,攻击者只要在请求里手动加上这个头,就能骗过 Next.js,让它以为中间件已经递归执行了足够多次,然后乖乖地放行。
5.2 攻击方式
攻击载荷因 Next.js 版本而异,但都简单得令人不安:
# Pages Router(版本 11.1.4 – 12.1.x)
curl -H "x-middleware-subrequest: pages/_middleware" https://target.com/admin
# App Router(版本 12.2.x – 13.x)
curl -H "x-middleware-subrequest: middleware:middleware:middleware:middleware:middleware" \
https://target.com/admin
# App Router + src 目录(版本 14.x – 15.2.2)
curl -H "x-middleware-subrequest: src/middleware:src/middleware:src/middleware:src/middleware:src/middleware" \
https://target.com/admin
就这么简单。一行 curl 命令,你精心设置的认证检查、授权逻辑、CSP 策略——全部形同虚设。说实话,第一次看到这个 PoC 的时候我是有点震惊的。
5.3 影响范围
- 认证绕过:攻击者可以直接访问受保护的路由,就好像中间件不存在一样
- CSP 绕过:你在中间件里辛苦设置的 Content Security Policy 头?没了
- 缓存投毒:在某些配置下,被绕过的中间件可能导致缓存里存进不安全的内容
- 受影响版本:Next.js 11.1.4 到 15.2.2——覆盖面相当广
5.4 修复与缓解措施
第一步:立即升级(这个没啥好犹豫的)
- Next.js 15.x → 升级到 15.2.3+
- Next.js 14.x → 升级到 14.2.25+
- Next.js 13.x → 升级到 13.5.9+
第二步:如果实在没法马上升级,在反向代理层把那个请求头干掉
# Nginx 配置
proxy_set_header x-middleware-subrequest "";
# Apache 配置
RequestHeader unset x-middleware-subrequest
第三步:纵深防御——这才是真正的治本之策
这个漏洞给我们上了最重要的一课:永远不要把中间件当作唯一的安全屏障。就像我们在认证那篇文章里反复强调的,你需要在每一个数据访问点都做验证。这不是多余,这是必须。
// 不要只在中间件中验证!
// 在 Server Component 中也要验证
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';
import { verifySession } from '@/lib/auth';
export default async function DashboardPage() {
const cookieStore = await cookies();
const token = cookieStore.get('session-token')?.value;
if (!token) {
redirect('/login');
}
const session = await verifySession(token);
if (!session) {
redirect('/login');
}
// 安全地渲染页面...
return 欢迎回来,{session.user.name};
}
// 在 Server Action 中也要验证
'use server';
import { verifySession } from '@/lib/auth';
import { cookies } from 'next/headers';
export async function deleteUser(userId: string) {
const cookieStore = await cookies();
const token = cookieStore.get('session-token')?.value;
const session = await verifySession(token);
if (!session || session.user.role !== 'admin') {
throw new Error('未授权操作');
}
// 执行删除操作...
}
Vercel 托管的应用已自动受到保护。但如果你是自托管,那必须自行修补或实施上面的缓解措施,这不是可选项。
六、Next.js 16 的架构演进:从 Middleware 到 Proxy
6.1 为什么要改名?
Next.js 16 里,middleware.ts 被正式重命名为 proxy.ts。这背后不是拍脑袋的决定,而是有很深的架构考量:
- 消除概念混淆:"中间件"这个词太容易让人联想到 Express.js 的 middleware 模式了。太多开发者把它当成万能逻辑层来用——数据库查询、复杂计算、完整的认证流程全往里塞。但 Next.js 的中间件本质上是一个网络代理,运行在应用前方,只该负责请求级别的轻量处理。
- 反映真实架构:"代理"(Proxy)更准确地描述了它的行为——它有网络边界,位于应用的前方,默认运行在 Edge Runtime 上,可以部署在离客户端更近的位置。这些特征用"代理"来概括再合适不过了。
- 引导正确的使用姿势:改名的一大目标,就是让开发者自觉地把复杂逻辑往 Server Actions、API Routes 和服务器组件里搬,而不是往代理里堆。
6.2 如何迁移
方式一:用官方 Codemod(推荐,省事儿)
npx @next/codemod@latest middleware-to-proxy .
这个 codemod 会帮你自动完成:
- 把
middleware.ts重命名为proxy.ts - 导出函数名从
middleware改为proxy - 更新
next.config.js中相关的配置属性名称
方式二:手动迁移
// 迁移前:middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
// 你的逻辑...
return NextResponse.next();
}
export const config = {
matcher: ['/dashboard/:path*'],
skipMiddlewareUrlNormalize: true,
};
// 迁移后:proxy.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function proxy(request: NextRequest) {
// 逻辑不变
return NextResponse.next();
}
export const config = {
matcher: ['/dashboard/:path*'],
skipProxyUrlNormalize: true, // 注意属性名也变了
};
6.3 配置属性名变更对照表
| 旧名称(middleware) | 新名称(proxy) |
|---|---|
skipMiddlewareUrlNormalize | skipProxyUrlNormalize |
experimental.middlewarePrefetch | experimental.proxyPrefetch |
experimental.middlewareClientMaxBodySize | experimental.proxyClientMaxBodySize |
experimental.externalMiddlewareRewritesResolve | experimental.externalProxyRewritesResolve |
6.4 对认证库的影响
这次改名在整个 Next.js 认证生态里引发了连锁反应:
- NextAuth(Auth.js)、Clerk 等认证库都需要适配新的命名约定
- 更深层的信号是:别再把认证当作中间件/代理的主要职责了
- 新一代开发者从起步阶段就会学习在 Server Actions 和 API Routes 中验证会话,而不是一股脑依赖中间件
重要提示:proxy.ts 的运行时固定为 nodejs,不可配置。如果你需要继续使用 Edge Runtime,就保留 middleware.ts(已标记 deprecated,但目前仍然能用)。
七、单元测试中间件
7.1 使用 Next.js 15.1+ 的测试工具
从 Next.js 15.1 开始,官方提供了实验性的测试工具来帮你对中间件进行单元测试。终于不用纯手工 mock 了。
// __tests__/middleware.test.ts
import { unstable_doesMiddlewareMatch } from 'next/experimental/testing/server';
describe('中间件匹配测试', () => {
it('应该匹配 dashboard 路由', () => {
const result = unstable_doesMiddlewareMatch({
config: {
matcher: ['/dashboard/:path*'],
},
nextConfig: {},
url: '/dashboard/settings',
});
expect(result).toBe(true);
});
it('不应该匹配静态资源', () => {
const result = unstable_doesMiddlewareMatch({
config: {
matcher: [
'/((?!_next/static|_next/image|favicon.ico).*)',
],
},
nextConfig: {},
url: '/_next/static/chunks/main.js',
});
expect(result).toBe(false);
});
});
7.2 测试中间件逻辑
至于中间件的业务逻辑测试,你可以构造 mock 请求来验证行为:
// __tests__/middleware-logic.test.ts
import { NextRequest } from 'next/server';
import { middleware } from '../middleware';
function createMockRequest(
url: string,
options?: { cookies?: Record<string, string>; headers?: Record<string, string> }
): NextRequest {
const request = new NextRequest(new URL(url, 'http://localhost:3000'));
if (options?.cookies) {
Object.entries(options.cookies).forEach(([key, value]) => {
request.cookies.set(key, value);
});
}
return request;
}
describe('认证中间件逻辑', () => {
it('未登录用户访问 dashboard 应重定向到 login', () => {
const request = createMockRequest('/dashboard');
const response = middleware(request);
expect(response.status).toBe(307);
expect(response.headers.get('location')).toContain('/login');
});
it('已登录用户访问 dashboard 应放行', () => {
const request = createMockRequest('/dashboard', {
cookies: { 'session-token': 'valid-token-123' },
});
const response = middleware(request);
expect(response.status).toBe(200);
});
it('应设置安全响应头', () => {
const request = createMockRequest('/about');
const response = middleware(request);
expect(response.headers.get('X-Frame-Options')).toBe('DENY');
expect(response.headers.get('X-Content-Type-Options')).toBe('nosniff');
});
});
八、性能优化:别让中间件成为瓶颈
8.1 保持轻量
中间件在每个匹配的请求上都要跑一遍,所以性能绝对不能马虎。核心原则就这么几条:
- 不要做数据库查询——中间件只该做轻量级检查(读 cookie、验 JWT 签名之类的)
- 不要做复杂计算——CPU 密集型的操作会阻塞请求,得不偿失
- 不要调用慢速外部服务——非要调的话,记得设严格的超时
- 最小化导入——每个 import 都在增加冷启动时间,Edge Runtime 下尤为明显
// ❌ 错误示范:中间件做了太多事情
import { PrismaClient } from '@prisma/client'; // Edge Runtime 中不可用!
export async function middleware(request: NextRequest) {
const prisma = new PrismaClient();
const user = await prisma.user.findUnique({...}); // 太慢了
const permissions = await fetch('https://slow-api.com/permissions'); // 外部调用
// ...大量逻辑
}
// ✅ 正确示范:中间件只做轻量级检查
import { jwtVerify } from 'jose'; // 轻量级、Edge 兼容
export async function middleware(request: NextRequest) {
const token = request.cookies.get('token')?.value;
if (!token) {
return NextResponse.redirect(new URL('/login', request.url));
}
try {
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
await jwtVerify(token, secret);
return NextResponse.next();
} catch {
return NextResponse.redirect(new URL('/login', request.url));
}
}
8.2 精确的 Matcher 配置
让中间件只在真正需要的路径上运行。别浪费 CPU 在静态资源和公开页面上:
export const config = {
matcher: [
// 只保护需要认证的路由
'/dashboard/:path*',
'/settings/:path*',
'/api/((?!public|health|webhook).*)',
],
};
8.3 监控执行时间
生产环境中,建议监控中间件的执行时间和错误率。哪怕是最简单的计时日志也比什么都没有强:
export async function middleware(request: NextRequest) {
const start = Date.now();
try {
// 中间件逻辑...
const response = NextResponse.next();
const duration = Date.now() - start;
// 将执行时间记录到响应头中(方便调试)
response.headers.set('x-middleware-duration', `${duration}ms`);
if (duration > 50) {
// 中间件执行超过 50ms 可能需要优化
console.warn(`Middleware slow: ${duration}ms for ${request.nextUrl.pathname}`);
}
return response;
} catch (error) {
console.error('Middleware error:', error);
return NextResponse.next(); // 出错时不阻塞请求
}
}
九、完整实战案例:生产级中间件配置
好了,理论讲得差不多了。来把前面所有知识点攒成一个完整的生产级中间件配置吧:
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { jwtVerify } from 'jose';
// ============= 配置 =============
const PUBLIC_PATHS = ['/login', '/register', '/forgot-password', '/about', '/pricing'];
const PROTECTED_PATHS = ['/dashboard', '/settings', '/profile', '/admin'];
const ADMIN_PATHS = ['/admin'];
const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET || 'fallback-secret');
// ============= 辅助函数 =============
interface TokenPayload {
sub: string;
role: string;
exp: number;
}
async function verifyToken(token: string): Promise<TokenPayload | null> {
try {
const { payload } = await jwtVerify(token, JWT_SECRET);
return payload as unknown as TokenPayload;
} catch {
return null;
}
}
function isPathMatch(pathname: string, paths: string[]): boolean {
return paths.some(path => pathname === path || pathname.startsWith(`${path}/`));
}
// ============= 中间件主函数 =============
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
const token = request.cookies.get('auth-token')?.value;
// 1. 安全头(所有请求都添加)
const requestHeaders = new Headers(request.headers);
requestHeaders.set('x-request-id', crypto.randomUUID());
// 2. 公开路径:已登录用户重定向到 dashboard
if (isPathMatch(pathname, PUBLIC_PATHS) && token) {
const payload = await verifyToken(token);
if (payload) {
return NextResponse.redirect(new URL('/dashboard', request.url));
}
}
// 3. 受保护路径:验证登录状态
if (isPathMatch(pathname, PROTECTED_PATHS)) {
if (!token) {
const loginUrl = new URL('/login', request.url);
loginUrl.searchParams.set('callbackUrl', pathname);
return NextResponse.redirect(loginUrl);
}
const payload = await verifyToken(token);
if (!payload) {
// Token 无效或过期,清除 cookie 并重定向
const response = NextResponse.redirect(new URL('/login', request.url));
response.cookies.delete('auth-token');
return response;
}
// 4. 管理员路径:验证角色
if (isPathMatch(pathname, ADMIN_PATHS) && payload.role !== 'admin') {
return NextResponse.redirect(new URL('/dashboard', request.url));
}
// 将用户信息传递给下游
requestHeaders.set('x-user-id', payload.sub);
requestHeaders.set('x-user-role', payload.role);
}
// 5. 构造响应
const response = NextResponse.next({
request: { headers: requestHeaders },
});
// 6. 安全响应头
response.headers.set('X-Frame-Options', 'DENY');
response.headers.set('X-Content-Type-Options', 'nosniff');
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
response.headers.set('X-DNS-Prefetch-Control', 'on');
response.headers.set(
'Strict-Transport-Security',
'max-age=63072000; includeSubDomains; preload'
);
return response;
}
// ============= Matcher 配置 =============
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt|.*\\..*).*)',
],
};
十、总结与最佳实践清单
在 Next.js 的架构中,中间件(或者说即将全面接班的 Proxy)是一把需要谨慎使用的利器。来总结一下核心要点吧。
该做的
- 用 matcher 精确控制作用范围——始终排除静态资源,只匹配真正需要处理的路由
- 保持中间件轻量——只做 cookie 检查、JWT 验证、重定向/重写这些快速操作
- 实施纵深防御——在 Server Components、Server Actions、API Routes 中都要做认证授权验证
- 模块化组织逻辑——不同关注点拆到独立模块里,别堆在一个大函数中
- 编写单元测试——用 Next.js 15.1+ 的测试工具验证 matcher 配置和业务逻辑
- 监控性能——追踪执行时间,超过 50ms 就该优化了
- 尽快升级——确保至少在用 Next.js 15.2.3+ 来修复 CVE-2025-29927
不该做的
- 不要在中间件中做数据库查询——这不是它该干的活儿,Edge Runtime 下也做不到
- 不要把中间件当唯一的安全屏障——CVE-2025-29927 已经用血的教训证明了这一点
- 不要调用慢速外部 API——每个请求都会受拖累
- 不要忽视无限重定向的风险——跳转前永远先检查目标路径
- 不要在中间件里往 cookie 存敏感数据——这个环境可能没你以为的那么安全
面向未来
- 着手规划
proxy.ts迁移——middleware.ts目前还能用,但已经是 deprecated 状态了 - 把复杂逻辑搬到 Server Actions 和 API Routes 里去
- 持续关注 Next.js 的演进方向:PPR(Partial Prerendering)、Streaming 等新特性正在重塑整个架构格局
中间件不是万能的,但放对了位置,它就是你应用里最高效的第一道防线。搞清楚它的能力边界,比盲目依赖它重要得多。希望这篇指南能帮你建立正确的心智模型,在真实项目中做出更靠谱的架构决策。