Next.js Middleware 是 Vercel 平台上最常被低估的利器:它在每个页面请求的"最前端"执行——在 Vercel 的基础设施上,这意味着代码在全球边缘节点运行,延迟低于 5ms。你可以用它来拦截请求、验证身份、做 A/B 测试、多语言路由、灰度发布,甚至在请求到达应用逻辑之前完成复杂的准入校验。本文覆盖 Middleware 的完整能力边界和最佳实践。
一、Middleware 基础定位
1.1 执行位置
用户请求
↓
DNS 解析(Cloudflare/Vercel Edge)
↓
Next.js Middleware(Edge Runtime,全球边缘节点)
├── 可以:重定向(redirect)、重写(rewrite)、修改请求头
├── 可以:读取 cookie、geolocation、IP
└── 不能:直接操作数据库(无 TCP)、无 fs
↓
Next.js 页面路由 / API Route
├── Edge Functions(如果声明 runtime = 'edge')
└── Serverless Functions(Node.js Runtime)
1.2 与 Edge Functions 的区别
| 维度 | Middleware | Edge Functions |
|---|---|---|
| 执行时机 | 请求最先到达 | 路由匹配后 |
| 主要用途 | 准入控制、路由决策、注入上下文 | 业务逻辑、API 响应、流式输出 |
| 返回类型 | NextResponse(继续/重定向/重写) | Response(任意 HTTP 响应) |
| 可否阻断请求 | ✅ 直接返回 403/401 | ✅ 返回任意状态码 |
| 修改请求头 | ✅ 可以 | ✅ 可以 |
| 可访问数据库 | ❌ 直接不行 | ❌ 直接不行(需 HTTP 代理) |
最佳心智模型:
- Middleware 是"门禁 + 礼宾":决定让不让你进、走哪个门、附赠什么信息
- Edge Functions 是"服务员":进门后的具体业务处理
二、基础用法
2.1 最小可运行示例
// middleware.ts(项目根目录)
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
// 简单的日志
console.log(`[${request.method}] ${request.url}`);
// 让请求继续
return NextResponse.next();
}
// 匹配规则:只在页面路由上执行,排除 API/静态资源
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico|robots.txt|sitemap.xml).*)'],
};
2.2 重定向(Redirect)
// middleware.ts
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// 旧 URL 迁移
if (pathname.startsWith('/old-blog/')) {
const newPath = pathname.replace('/old-blog/', '/blog/');
return NextResponse.redirect(new URL(newPath, request.url));
}
// 强制 HTTPS
if (request.headers.get('x-forwarded-proto') === 'http') {
return NextResponse.redirect(
new URL(request.nextUrl.pathname, request.url).toString().replace('http:', 'https:'),
301
);
}
return NextResponse.next();
}
2.3 重写(Rewrite)
Rewrite 和 Redirect 的区别:
- Redirect:用户浏览器收到 301/302,地址栏 URL 改变
- Rewrite:用户浏览器不知道 URL 被改了,地址栏保持不变(服务器内部转发)
// middleware.ts
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// 把 /docs 重写为 /documentation(URL 不变)
if (pathname === '/docs') {
return NextResponse.rewrite(new URL('/documentation', request.url));
}
// 多语言重写:/about → /en/about(URL 不变)
if (!pathname.startsWith('/en') && !pathname.startsWith('/zh')) {
return NextResponse.rewrite(new URL(`/en${pathname}`, request.url));
}
return NextResponse.next();
}
2.4 Cookie 操作
// middleware.ts
export function middleware(request: NextRequest) {
const response = NextResponse.next();
// 读取 cookie
const theme = request.cookies.get('theme')?.value || 'light';
// 设置 cookie(响应给用户浏览器)
response.cookies.set('theme', theme, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 60 * 60 * 24 * 30, // 30 天
});
// 删除 cookie
// response.cookies.delete('theme');
return response;
}
三、生产级认证中间件
3.1 JWT 验证(Edge 兼容)
// lib/auth-edge.ts
import { jwtVerify } from 'jose';
const secret = new TextEncoder().encode(process.env.JWT_SECRET!);
export async function verifyAuth(request: NextRequest) {
const token = request.cookies.get('token')?.value;
if (!token) return null;
try {
const { payload } = await jwtVerify(token, secret, { clockTolerance: 60 });
return payload;
} catch {
return null;
}
}
3.2 路由级权限控制
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { verifyAuth } from './lib/auth-edge';
// 公开路由(无需登录)
const publicRoutes = ['/', '/login', '/register', '/about', '/api/auth'];
// 静态资源(不经过认证)
const staticExtensions = ['.js', '.css', '.png', '.jpg', '.ico', '.svg', '.woff2'];
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// 跳过静态资源
if (staticExtensions.some(ext => pathname.endsWith(ext))) {
return NextResponse.next();
}
// 跳过公开路由
if (publicRoutes.some(route => pathname === route || pathname.startsWith(`${route}/`))) {
return NextResponse.next();
}
// 验证身份
const user = await verifyAuth(request);
if (!user) {
// 未登录 → 重定向到登录页,并记录原始目标
const loginUrl = new URL('/login', request.url);
loginUrl.searchParams.set('redirect', pathname);
return NextResponse.redirect(loginUrl);
}
// 已登录 → 注入用户信息到请求头
const requestHeaders = new Headers(request.headers);
requestHeaders.set('x-user-id', user.sub as string);
requestHeaders.set('x-user-email', user.email as string);
requestHeaders.set('x-user-role', (user.role as string) || 'user');
const response = NextResponse.next({ request: { headers: requestHeaders } });
// 刷新 token(如果快过期了)
const exp = user.exp as number;
if (exp && exp - Date.now() / 1000 < 3600) {
response.cookies.set('token-refresh', 'true', { httpOnly: true });
}
return response;
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)'],
};
下游页面使用注入的信息:
// app/dashboard/page.tsx
export default async function DashboardPage({ request }: { request: Request }) {
// 从请求头读取 Middleware 注入的用户信息
const headers = new Headers(request.headers);
const userId = headers.get('x-user-id');
const userEmail = headers.get('x-user-email');
return (
<main>
<h1>Dashboard</h1>
<p>Welcome, {userEmail}</p>
</main>
);
}
3.3 Role-Based 访问控制
// middleware.ts
const roleRoutes: Record<string, string[]> = {
admin: ['/admin', '/dashboard', '/settings'],
editor: ['/dashboard', '/posts'],
user: ['/dashboard'],
};
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
const userRole = (await verifyAuth(request))?.role as string || 'anonymous';
// 检查当前路由是否在角色允许列表中
const allowedRoutes = roleRoutes[userRole] || [];
const hasAccess = allowedRoutes.some(route => pathname.startsWith(route));
if (!hasAccess) {
return NextResponse.rewrite(new URL('/403', request.url));
}
return NextResponse.next();
}
四、A/B 测试
4.1 基于 Cookie 的用户分桶
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
function getBucket(request: NextRequest): 'a' | 'b' {
const bucket = request.cookies.get('ab-bucket')?.value;
if (bucket === 'a' || bucket === 'b') return bucket;
// 新用户随机分桶(50/50)
return Math.random() < 0.5 ? 'a' : 'b';
}
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// 只在首页做 A/B 测试
if (pathname !== '/') return NextResponse.next();
const bucket = getBucket(request);
const response = NextResponse.rewrite(new URL(`/home-${bucket}`, request.url));
// 给新用户设置 bucket cookie
if (!request.cookies.get('ab-bucket')) {
response.cookies.set('ab-bucket', bucket, {
httpOnly: true,
maxAge: 60 * 60 * 24 * 30, // 30 天
});
}
return response;
}
对应的页面结构:
app/
├── home-a/
│ └── page.tsx # A 版本首页
├── home-b/
│ └── page.tsx # B 版本首页
└── page.tsx # 备用(万一 rewrite 失败)
4.2 与 Analytics 联动的 A/B 测试
在 Middleware 中把 bucket 信息注入请求头,供页面和 Analytics 使用:
// middleware.ts
export function middleware(request: NextRequest) {
const bucket = getBucket(request);
const response = NextResponse.rewrite(new URL(`/home-${bucket}`, request.url));
response.headers.set('x-ab-bucket', bucket);
return response;
}
页面中上报:
// app/home-a/page.tsx
export default function HomeA() {
// 使用 Vercel Web Analytics 或自定义上报
useEffect(() => {
va.track('ab-test-view', { bucket: 'a', variant: 'new-hero' });
}, []);
return <HeroVariantA />;
}
五、国际化(i18n)路由
5.1 多语言自动路由
// middleware.ts
import { NextResponse } from 'next/server';
import { match } from '@formatjs/intl-localematcher';
import Negotiator from 'negotiator';
const locales = ['en', 'zh', 'ja'];
const defaultLocale = 'en';
function getLocale(request: NextRequest): string {
// 1. 已有 cookie 决定
const cookieLocale = request.cookies.get('NEXT_LOCALE')?.value;
if (cookieLocale && locales.includes(cookieLocale)) return cookieLocale;
// 2. 从 Accept-Language 头解析
const headers = { 'accept-language': request.headers.get('accept-language') || '' };
const languages = new Negotiator({ headers }).languages();
return match(languages, locales, defaultLocale);
}
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// 如果路径已有语言前缀,跳过
const pathnameHasLocale = locales.some(
locale => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
);
if (pathnameHasLocale) return NextResponse.next();
// 默认语言不添加前缀(SEO 友好)
const locale = getLocale(request);
if (locale === defaultLocale) {
return NextResponse.next();
}
// 其他语言重定向到 /{locale}/path
request.nextUrl.pathname = `/${locale}${pathname}`;
return NextResponse.redirect(request.nextUrl);
}
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico|.*\\.).*)'],
};
5.2 按地理位置自动选择语言
// middleware.ts
export function middleware(request: NextRequest) {
const country = request.geo?.country || 'US';
const countryToLocale: Record<string, string> = {
CN: 'zh', TW: 'zh', HK: 'zh',
JP: 'ja', KR: 'ko',
DE: 'de', FR: 'fr',
};
const locale = countryToLocale[country] || 'en';
const response = NextResponse.next();
response.cookies.set('NEXT_LOCALE', locale);
return response;
}
六、灰度发布
6.1 基于 IP 的白名单灰度
// middleware.ts
const whitelist = [
'203.0.113.0/24', // 公司办公网 IP 段
'198.51.100.1', // 测试服务器
];
function ipInRange(ip: string, range: string): boolean {
// 简化的 CIDR 匹配(生产环境建议用完整的 CIDR 库)
if (!range.includes('/')) return ip === range;
const [base, bits] = range.split('/');
const mask = parseInt(bits);
const ipParts = ip.split('.').map(Number);
const baseParts = base.split('.').map(Number);
const ipNum = ipParts.reduce((a, b) => (a << 8) + b, 0);
const baseNum = baseParts.reduce((a, b) => (a << 8) + b, 0);
const maskNum = ~((1 << (32 - mask)) - 1);
return (ipNum & maskNum) === (baseNum & maskNum);
}
export function middleware(request: NextRequest) {
const ip = request.ip || request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || '';
// 白名单用户看到新版本
const isWhitelisted = whitelist.some(range => ipInRange(ip, range));
if (request.nextUrl.pathname === '/' && isWhitelisted) {
return NextResponse.rewrite(new URL('/home-v2', request.url));
}
return NextResponse.next();
}
6.2 基于百分比流量的灰度
// middleware.ts
function isInRollout(ip: string, percent: number): boolean {
// 用 IP 地址的哈希值决定用户是否在灰度范围内(保证同一用户始终进同一组)
let hash = 0;
for (let i = 0; i < ip.length; i++) {
hash = ((hash << 5) - hash + ip.charCodeAt(i)) | 0;
}
return (Math.abs(hash) % 100) < percent;
}
export function middleware(request: NextRequest) {
const ip = request.ip || '';
const inRollout = isInRollout(ip, 10); // 10% 灰度
if (request.nextUrl.pathname === '/' && inRollout) {
return NextResponse.rewrite(new URL('/home-beta', request.url));
}
return NextResponse.next();
}
七、会话管理与安全
7.1 CSRF 保护
// middleware.ts
export function middleware(request: NextRequest) {
if (request.method === 'POST' || request.method === 'PUT' || request.method === 'DELETE') {
const origin = request.headers.get('origin');
const allowedOrigins = ['https://yoursite.com', 'https://app.yoursite.com'];
if (!origin || !allowedOrigins.includes(origin)) {
return new Response('CSRF detected', { status: 403 });
}
}
return NextResponse.next();
}
7.2 IP 限流(Rate Limiting)
// lib/rate-limit.ts
// 简化的内存限流器(生产环境用 Redis/Upstash)
const requests = new Map<string, { count: number; resetAt: number }>();
export function isRateLimited(ip: string, limit: number, windowMs: number): boolean {
const now = Date.now();
const record = requests.get(ip);
if (!record || now > record.resetAt) {
requests.set(ip, { count: 1, resetAt: now + windowMs });
return false;
}
record.count++;
return record.count > limit;
}
// middleware.ts
import { isRateLimited } from './lib/rate-limit';
export function middleware(request: NextRequest) {
const ip = request.ip || '';
if (isRateLimited(ip, 100, 60000)) { // 每分钟 100 次
return new Response('Too Many Requests', { status: 429 });
}
return NextResponse.next();
}
⚠️ 以上内存储存重启后失效。生产环境推荐用 Upstash Redis(Edge 兼容)。
八、性能优化与注意事项
8.1 Matcher 配置的重要性
// ❌ 不推荐:匹配所有路径(包括静态资源)
export const config = {
matcher: '/:path*',
};
// ✅ 推荐:只匹配页面路由
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico|.*\\..*).*)'],
};
// ✅ 或指定具体路径
export const config = {
matcher: ['/', '/about', '/dashboard/:path*'],
};
Matcher 越精确,Middleware 执行次数越少,费用越低。
8.2 冷启动优化
Middleware 冷启动已极低(< 5ms),但仍需注意:
// ❌ 不推荐:在全局执行重操作
import { heavyCompute } from './lib/compute'; // 这行在模块加载时执行
// ✅ 推荐:延迟到函数内部
export async function middleware(request: NextRequest) {
const { heavyCompute } = await import('./lib/compute');
if (needCompute) {
heavyCompute();
}
return NextResponse.next();
}
8.3 避免死循环
// ❌ 危险:重写到同一路径会造成死循环
if (pathname === '/login') {
return NextResponse.redirect(new URL('/login', request.url));
}
// ✅ 正确:提前退出条件
if (pathname === '/login') {
return NextResponse.next(); // 已经在目标路径,无需重定向
}
常见问题(FAQ)
Middleware 能连接数据库做认证校验吗?
不能直接连接。Middleware 运行在 Edge Runtime,没有 TCP Socket,不能直连 Postgres/MySQL。替代方案:
- 用 JWT/Session Cookie 做无状态验证(推荐)
- 通过 HTTP API 查询外部认证服务(如 Auth0、Clerk 的 API)
- 用 Edge 兼容的 KV 存储(Upstash Redis、Vercel KV)保存会话信息
为什么我的 Middleware 没有被执行?
排查步骤:
- 确认
middleware.ts放在项目根目录(与app/、package.json同级) - 确认
config.matcher匹配了你的目标路径 - 确认没有被
npm run dev的缓存影响,尝试重启开发服务器 - 在 Vercel Dashboard → Logs → 查看 Middleware 执行日志
Middleware 能修改响应体吗?
不能直接修改 HTML 响应体。Middleware 只能:
- 重定向/重写 URL
- 修改请求头/响应头
- 设置/删除 cookie
如果需要修改 HTML(如注入脚本),用 Next.js experimental-ops 或在页面级别使用其他方案。
多个 Middleware 如何组合?
Next.js 只支持一个 middleware.ts 文件。如果需要多个功能,在同一文件中按条件组合:
export async function middleware(request: NextRequest) {
// 1. 认证检查
const authResult = await authMiddleware(request);
if (authResult) return authResult;
// 2. A/B 测试
const abResult = await abTestMiddleware(request);
if (abResult) return abResult;
// 3. 国际化
const i18nResult = i18nMiddleware(request);
if (i18nResult) return i18nResult;
return NextResponse.next();
}
Middleware 执行会贡献到 Vercel 账单吗?
会的。Middleware 每执行一次就产生一次 Edge 请求。虽然单条请求成本极低(Pro 包含 2500 万次/月),但流量大的站点需要注意 Matcher 匹配范围——排除静态资源可以大幅减少调用次数。
相关阅读
- Vercel 详解:前端与 AI 应用的一站式云平台
- Vercel Edge Functions 深度指南
- Vercel AI SDK 指南
- Vercel 国内访问优化指南
- Vercel 定价与成本详解
- Vercel 专题导航
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。