本章目标:全面掌握 Next.js Middleware——理解其执行时机、Edge Runtime 限制,学会实现路由守卫、国际化、A/B 测试、请求重写等高级功能,并掌握性能优化与调试技巧。
12.1 Middleware 概述
什么是 Middleware?
Middleware 是 Next.js 中在请求到达页面之前执行的代码层。它可以在 Edge Runtime 上运行,具有以下能力:
- 重写(Rewrite)请求路径
- 重定向(Redirect)到其他 URL
- 修改请求/响应 Headers
- 拦截并返回自定义响应
- 执行认证检查
请求流程:
Client Request
↓
Next.js Middleware(Edge Runtime)
↓ 可以:重写、重定向、修改 Headers、拦截
Page / API Route(Node.js Runtime)
↓
Response
文件位置
// middleware.ts(项目根目录,与 app/ 同级)
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*', '/api/protected/:path*'],
};
执行时机
请求生命周期:
1. Client 发起请求
2. Middleware 执行(Edge Runtime)
- 可以读取 / 修改 request
- 可以读取 / 修改 headers
- 可以返回重定向或自定义响应
3. 如果 Middleware 调用 NextResponse.next():
- 请求继续到 Page / API Route
4. Page / API Route 执行(Node.js Runtime)
5. Response 返回给 Client
12.2 Edge Runtime 限制
Middleware 运行在 Edge Runtime 上,这意味着:
可用的 API
// ✅ 可用:Web 标准 API
- Request / Response / Headers
- URL / URLSearchParams
- fetch()
- ReadableStream / WritableStream
- TextEncoder / TextDecoder
- crypto.subtle (Web Crypto API)
- setTimeout(仅 Vercel Edge)
不可用的 API
// ❌ 不可用:Node.js 特有 API
- fs / path(文件系统)
- child_process(子进程)
- net / http(原生网络)
- Prisma(需要 Node.js)
- bcrypt(需要 Node.js)
- mongoose(需要 Node.js)
替代方案
// ❌ 不能用 Prisma
import { prisma } from '@/lib/prisma';
const user = await prisma.user.findUnique({ where: { id } });
// ✅ 改用 fetch 调用 API
const res = await fetch(`${request.nextUrl.origin}/api/users/${id}`);
const user = await res.json();
// ❌ 不能用 bcrypt
import { compare } from 'bcryptjs';
const valid = await compare(password, hash);
// ✅ 改用 Web Crypto API
const encoder = new TextEncoder();
const data = encoder.encode(password);
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
12.3 基础用法
请求日志
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const start = Date.now();
// 记录请求信息
console.log(`[${new Date().toISOString()}] ${request.method} ${request.url}`);
const response = NextResponse.next();
// 添加自定义 Header
response.headers.set('X-Request-Id', crypto.randomUUID());
response.headers.set('X-Response-Time', `${Date.now() - start}ms`);
return response;
}
export const config = {
matcher: '/((?!_next/static|_next/image|favicon.ico).*)',
};
IP 地理位置
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
// Vercel 自动注入地理位置信息
const country = request.geo?.country || 'Unknown';
const city = request.geo?.city || 'Unknown';
const region = request.geo?.region || 'Unknown';
// 将地理信息传递给页面
const response = NextResponse.next();
response.headers.set('X-User-Country', country);
response.headers.set('X-User-City', city);
response.headers.set('X-User-Region', region);
// 根据地理位置重定向
if (country === 'CN') {
return NextResponse.redirect(new URL('/zh', request.url));
}
return response;
}
请求重写(Rewrite)
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// 将 /old-path 重写到 /new-path(浏览器地址栏不变)
if (pathname === '/old-path') {
return NextResponse.rewrite(new URL('/new-path', request.url));
}
// 将 /blog/:slug 重写到 /articles/:slug
if (pathname.startsWith('/blog/')) {
const slug = pathname.replace('/blog/', '');
return NextResponse.rewrite(new URL(`/articles/${slug}`, request.url));
}
return NextResponse.next();
}
条件重定向
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// 维护模式:将所有请求重定向到维护页面
if (process.env.MAINTENANCE_MODE === 'true' && pathname !== '/maintenance') {
return NextResponse.redirect(new URL('/maintenance', request.url));
}
// 旧链接重定向
if (pathname === '/about-us') {
return NextResponse.redirect(new URL('/about', request.url), 301); // 永久重定向
}
return NextResponse.next();
}
12.4 路由守卫(认证保护)
基础认证守卫
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { getToken } from 'next-auth/jwt';
// 公开路由白名单
const publicRoutes = [
'/',
'/login',
'/register',
'/articles',
'/about',
'/api/auth',
];
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// 跳过静态资源
if (
pathname.startsWith('/_next') ||
pathname.startsWith('/api/auth') ||
pathname.includes('.')
) {
return NextResponse.next();
}
// 检查是否是公开路由
const isPublicRoute = publicRoutes.some(
(route) => pathname === route || pathname.startsWith(route + '/')
);
if (isPublicRoute) {
return NextResponse.next();
}
// 验证 JWT Token
const token = await getToken({
req: request,
secret: process.env.AUTH_SECRET,
});
if (!token) {
// 未登录,重定向到登录页
const loginUrl = new URL('/login', request.url);
loginUrl.searchParams.set('callbackUrl', pathname);
return NextResponse.redirect(loginUrl);
}
// 已登录,继续
return NextResponse.next();
}
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico|.*\\..*).*)',
],
};
角色权限守卫
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { getToken } from 'next-auth/jwt';
// 路由权限配置
const routePermissions: Record<string, string[]> = {
'/admin': ['admin'],
'/admin/users': ['admin'],
'/admin/settings': ['admin'],
'/dashboard': ['admin', 'editor', 'user'],
'/dashboard/articles': ['admin', 'editor'],
};
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// 查找匹配的权限规则
const requiredRoles = Object.entries(routePermissions)
.filter(([route]) => pathname === route || pathname.startsWith(route + '/'))
.map(([, roles]) => roles);
// 如果没有权限规则,放行
if (requiredRoles.length === 0) {
return NextResponse.next();
}
// 验证 Token
const token = await getToken({
req: request,
secret: process.env.AUTH_SECRET,
});
if (!token) {
const loginUrl = new URL('/login', request.url);
loginUrl.searchParams.set('callbackUrl', pathname);
return NextResponse.redirect(loginUrl);
}
// 检查角色
const userRole = token.role as string;
const hasAccess = requiredRoles.some((roles) => roles.includes(userRole));
if (!hasAccess) {
return NextResponse.redirect(new URL('/403', request.url));
}
return NextResponse.next();
}
12.5 国际化(i18n)
检测用户语言
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
const locales = ['en', 'zh', 'ja', 'ko'];
const defaultLocale = 'en';
// 从 Accept-Language Header 解析首选语言
function getPreferredLocale(request: NextRequest): string {
const acceptLanguage = request.headers.get('Accept-Language');
if (!acceptLanguage) return defaultLocale;
// 解析 "zh-CN,zh;q=0.9,en;q=0.8"
const languages = acceptLanguage
.split(',')
.map((lang) => {
const [code, priority] = lang.trim().split(';q=');
return {
code: code.split('-')[0], // "zh-CN" → "zh"
priority: priority ? parseFloat(priority) : 1,
};
})
.sort((a, b) => b.priority - a.priority);
// 找到第一个支持的语言
for (const { code } of languages) {
if (locales.includes(code)) {
return code;
}
}
return defaultLocale;
}
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// 检查路径是否已包含语言前缀
const hasLocale = locales.some(
(locale) => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
);
if (hasLocale) {
return NextResponse.next();
}
// 检查 Cookie 中是否有保存的语言偏好
const cookieLocale = request.cookies.get('NEXT_LOCALE')?.value;
const locale = cookieLocale && locales.includes(cookieLocale)
? cookieLocale
: getPreferredLocale(request);
// 重定向到带语言前缀的路径
const url = new URL(`/${locale}${pathname}`, request.url);
url.search = request.nextUrl.search;
return NextResponse.redirect(url);
}
export const config = {
matcher: [
// 排除 API、静态文件、_next
'/((?!api|_next/static|_next/image|favicon.ico|.*\\..*).*)',
],
};
语言切换
// app/components/LanguageSwitcher.tsx
'use client';
import { usePathname, useRouter } from 'next/navigation';
const locales = [
{ code: 'en', name: 'English' },
{ code: 'zh', name: '中文' },
{ code: 'ja', name: '日本語' },
];
export function LanguageSwitcher() {
const pathname = usePathname();
const router = useRouter();
function switchLocale(locale: string) {
// 移除当前语言前缀
const pathWithoutLocale = pathname.replace(/^\/[a-z]{2}/, '');
// 设置 Cookie
document.cookie = `NEXT_LOCALE=${locale};path=/;max-age=31536000`;
// 重定向
router.push(`/${locale}${pathWithoutLocale}`);
}
return (
<select
onChange={(e) => switchLocale(e.target.value)}
className="px-2 py-1 border rounded text-sm"
>
{locales.map(({ code, name }) => (
<option key={code} value={code}>
{name}
</option>
))}
</select>
);
}
翻译字典
// lib/i18n/dictionaries.ts
const dictionaries = {
en: () => import('./dictionaries/en.json').then((m) => m.default),
zh: () => import('./dictionaries/zh.json').then((m) => m.default),
ja: () => import('./dictionaries/ja.json').then((m) => m.default),
};
export const getDictionary = async (locale: string) => {
const loader = dictionaries[locale as keyof typeof dictionaries];
return loader ? loader() : dictionaries.en();
};
// lib/i18n/dictionaries/zh.json
{
"common": {
"home": "首页",
"about": "关于",
"login": "登录",
"logout": "退出"
},
"article": {
"readMore": "阅读更多",
"publishedAt": "发布于",
"author": "作者"
}
}
使用方式:
// app/[locale]/page.tsx
import { getDictionary } from '@/lib/i18n/dictionaries';
export default async function HomePage({
params,
}: {
params: { locale: string };
}) {
const dict = await getDictionary(params.locale);
return (
<div>
<h1>{dict.common.home}</h1>
<a href={`/${params.locale}/about`}>{dict.common.about}</a>
</div>
);
}
12.6 A/B 测试
基于 Cookie 的分组
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// 只对首页进行 A/B 测试
if (pathname !== '/') {
return NextResponse.next();
}
// 检查是否已有分组 Cookie
let variant = request.cookies.get('ab-test-variant')?.value;
if (!variant) {
// 随机分配 A / B 组
variant = Math.random() > 0.5 ? 'A' : 'B';
const response = NextResponse.next();
response.cookies.set('ab-test-variant', variant, {
path: '/',
maxAge: 60 * 60 * 24 * 30, // 30 天
sameSite: 'lax',
});
// 将分组信息传递给页面
response.headers.set('X-AB-Variant', variant);
return response;
}
// 已有分组,直接传递
const response = NextResponse.next();
response.headers.set('X-AB-Variant', variant);
return response;
}
根据分组渲染不同内容
// app/page.tsx
import { headers } from 'next/headers';
export default async function HomePage() {
const headersList = await headers();
const variant = headersList.get('X-AB-Variant') || 'A';
return (
<div>
{variant === 'A' ? (
// 版本 A:强调功能
<section className="bg-blue-600 text-white py-20">
<h1 className="text-4xl font-bold">强大的 Next.js 教程</h1>
<p className="mt-4 text-xl">从零到生产,一站式学习</p>
<button className="mt-8 px-6 py-3 bg-white text-blue-600 rounded-lg font-semibold">
立即开始
</button>
</section>
) : (
// 版本 B:强调社区
<section className="bg-gradient-to-r from-purple-600 to-pink-600 text-white py-20">
<h1 className="text-4xl font-bold">加入 10,000+ 开发者社区</h1>
<p className="mt-4 text-xl">一起学习 Next.js,共同成长</p>
<button className="mt-8 px-6 py-3 bg-white text-purple-600 rounded-lg font-semibold">
免费加入
</button>
</section>
)}
</div>
);
}
基于路径的 A/B 测试(Rewrite)
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
if (pathname !== '/pricing') {
return NextResponse.next();
}
// 检查分组
let variant = request.cookies.get('pricing-test')?.value;
if (!variant) {
variant = Math.random() > 0.5 ? 'simple' : 'detailed';
const response = NextResponse.next();
response.cookies.set('pricing-test', variant, {
path: '/',
maxAge: 60 * 60 * 24 * 30,
});
// 重写到不同的页面
return NextResponse.rewrite(
new URL(`/pricing/${variant}`, request.url),
{ headers: { 'X-AB-Variant': variant } }
);
}
return NextResponse.rewrite(
new URL(`/pricing/${variant}`, request.url),
{ headers: { 'X-AB-Variant': variant } }
);
}
12.7 请求修改与 Headers
添加安全 Headers
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const response = NextResponse.next();
// 安全 Headers
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-XSS-Protection', '1; mode=block');
// Content Security Policy
response.headers.set(
'Content-Security-Policy',
[
"default-src 'self'",
"script-src 'self' 'unsafe-inline' 'unsafe-eval'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: https:",
"font-src 'self' data:",
"connect-src 'self' https://api.example.com",
].join('; ')
);
return response;
}
CORS 预检处理
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
const ALLOWED_ORIGINS = [
'http://localhost:3000',
'https://yourdomain.com',
];
export function middleware(request: NextRequest) {
const origin = request.headers.get('origin');
const isAllowed = origin && ALLOWED_ORIGINS.includes(origin);
// 处理 OPTIONS 预检请求
if (request.method === 'OPTIONS') {
return new NextResponse(null, {
status: 204,
headers: {
'Access-Control-Allow-Origin': isAllowed ? origin : ALLOWED_ORIGINS[0],
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Access-Control-Max-Age': '86400',
},
});
}
// 其他请求添加 CORS Headers
const response = NextResponse.next();
response.headers.set(
'Access-Control-Allow-Origin',
isAllowed ? origin : ALLOWED_ORIGINS[0]
);
return response;
}
export const config = {
matcher: '/api/:path*',
};
请求追踪
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const requestId = crypto.randomUUID();
const startTime = Date.now();
// 将 Request ID 传递给后续处理
const requestHeaders = new Headers(request.headers);
requestHeaders.set('X-Request-Id', requestId);
const response = NextResponse.next({
request: {
headers: requestHeaders,
},
});
// 在响应中也添加追踪信息
response.headers.set('X-Request-Id', requestId);
response.headers.set('X-Response-Time', `${Date.now() - startTime}ms`);
return response;
}
12.8 Matcher 配置详解
基础 Matcher
export const config = {
// 匹配单个路径
matcher: '/dashboard',
// 匹配路径及其子路径
matcher: '/dashboard/:path*',
// 匹配多个路径
matcher: ['/dashboard/:path*', '/api/protected/:path*'],
// 排除特定路径
matcher: '/((?!api|_next/static|_next/image).*)',
};
高级 Matcher(正则表达式)
export const config = {
matcher: [
// 匹配所有 /articles/:slug 路径
'/articles/:slug*',
// 匹配所有带语言前缀的路径
'/:locale(en|zh|ja)/:path*',
// 排除静态文件
'/((?!.*\\..*|_next).*)',
],
};
使用 next.config.js 中的 matcher
// next.config.js
module.exports = {
experimental: {
middlewareClientAuth: true, // 允许 Middleware 访问客户端认证
},
};
12.9 性能优化
减少 Middleware 执行时间
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// ❌ 避免:对所有请求执行复杂逻辑
// const user = await fetchUserFromDatabase(request);
// ✅ 推荐:只对需要的路径执行
if (!pathname.startsWith('/dashboard')) {
return NextResponse.next();
}
// 只在需要时执行认证检查
const token = request.cookies.get('auth-token');
if (!token) {
return NextResponse.redirect(new URL('/login', request.url));
}
return NextResponse.next();
}
缓存策略
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
// 简单的内存缓存(Edge Runtime 中每个实例独立)
const cache = new Map<string, { data: any; expires: number }>();
async function getCachedData(key: string, fetcher: () => Promise<any>, ttlMs: number) {
const cached = cache.get(key);
if (cached && cached.expires > Date.now()) {
return cached.data;
}
const data = await fetcher();
cache.set(key, { data, expires: Date.now() + ttlMs });
return data;
}
export async function middleware(request: NextRequest) {
// 使用缓存减少重复请求
const config = await getCachedData(
'site-config',
async () => {
const res = await fetch(`${request.nextUrl.origin}/api/config`);
return res.json();
},
60000 // 1 分钟缓存
);
// 使用配置...
return NextResponse.next();
}
12.10 调试与测试
本地调试
# 启动开发服务器
npm run dev
# Middleware 日志会输出到终端
# [middleware] GET /dashboard - 200 (15ms)
使用 console.log
// middleware.ts
export function middleware(request: NextRequest) {
console.log('=== Middleware Debug ===');
console.log('URL:', request.url);
console.log('Method:', request.method);
console.log('Headers:', Object.fromEntries(request.headers));
console.log('Cookies:', request.cookies.getAll());
console.log('Geo:', request.geo);
console.log('========================');
return NextResponse.next();
}
单元测试
// __tests__/middleware.test.ts
import { middleware } from '../middleware';
import { NextRequest } from 'next/server';
describe('Middleware', () => {
it('should redirect unauthenticated users to login', async () => {
const request = new NextRequest('http://localhost:3000/dashboard');
const response = await middleware(request);
expect(response.status).toBe(307);
expect(response.headers.get('Location')).toContain('/login');
});
it('should allow authenticated users', async () => {
const request = new NextRequest('http://localhost:3000/dashboard', {
headers: {
cookie: 'auth-token=valid-token',
},
});
const response = await middleware(request);
expect(response.status).toBe(200);
});
});
12.11 实战:完整的 Middleware 示例
// middleware.ts(完整版)
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { getToken } from 'next-auth/jwt';
// ============ 配置 ============
const LOCALES = ['en', 'zh'];
const DEFAULT_LOCALE = 'en';
const PUBLIC_ROUTES = [
'/',
'/login',
'/register',
'/articles',
'/about',
'/pricing',
];
const ADMIN_ROUTES = ['/admin'];
// ============ 辅助函数 ============
function getLocale(request: NextRequest): string {
const cookieLocale = request.cookies.get('NEXT_LOCALE')?.value;
if (cookieLocale && LOCALES.includes(cookieLocale)) {
return cookieLocale;
}
const acceptLanguage = request.headers.get('Accept-Language');
if (acceptLanguage) {
const preferred = acceptLanguage.split(',')[0].split('-')[0];
if (LOCALES.includes(preferred)) {
return preferred;
}
}
return DEFAULT_LOCALE;
}
function isPublicRoute(pathname: string): boolean {
return PUBLIC_ROUTES.some(
(route) => pathname === route || pathname.startsWith(route + '/')
);
}
function isAdminRoute(pathname: string): boolean {
return ADMIN_ROUTES.some(
(route) => pathname === route || pathname.startsWith(route + '/')
);
}
// ============ 主中间件 ============
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// 1. 跳过静态资源和 Next.js 内部路由
if (
pathname.startsWith('/_next') ||
pathname.startsWith('/api/auth') ||
pathname.includes('.')
) {
return NextResponse.next();
}
// 2. 国际化处理
const hasLocale = LOCALES.some(
(locale) => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
);
if (!hasLocale && isPublicRoute(pathname)) {
const locale = getLocale(request);
const url = new URL(`/${locale}${pathname}`, request.url);
url.search = request.nextUrl.search;
return NextResponse.redirect(url);
}
// 3. 安全 Headers
const response = NextResponse.next();
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');
// 4. 请求追踪
const requestId = crypto.randomUUID();
response.headers.set('X-Request-Id', requestId);
// 5. 认证检查(仅对非公开路由)
if (!isPublicRoute(pathname)) {
const token = await getToken({
req: request,
secret: process.env.AUTH_SECRET,
});
if (!token) {
const loginUrl = new URL('/login', request.url);
loginUrl.searchParams.set('callbackUrl', pathname);
return NextResponse.redirect(loginUrl);
}
// 6. 管理员权限检查
if (isAdminRoute(pathname)) {
if (token.role !== 'admin') {
return NextResponse.redirect(new URL('/403', request.url));
}
}
}
return response;
}
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico|.*\\..*).*)',
],
};
本章小结
Key Takeaways
- Middleware 在 Edge Runtime 上运行:不能使用 Node.js 特有 API(fs、Prisma、bcrypt)
- 执行时机在请求到达页面之前:适合做路由守卫、国际化、A/B 测试
- Matcher 配置决定哪些路由触发 Middleware:精确配置避免不必要的执行
- 认证守卫应该结合
getToken使用:不要在 Middleware 中直接查数据库 - 性能是关键:Middleware 在每个请求上都会执行,保持逻辑简洁
- 可以修改请求和响应 Headers:用于安全、追踪、CORS 等
下一步
Phase 4(数据库、认证与中间件)到此全部完成!🎉
下一卷 卷 V:UI 工程化 将涵盖:
- 第 13 章:Tailwind CSS 深度集成(主题定制、插件、暗色模式)
- 第 14 章:组件库选型与实战(shadcn/ui、MUI、Ant Design)
- 第 15 章:状态管理(Zustand、Jotai、Redux Toolkit)
- 第 16 章:表单与验证(React Hook Form + Zod)
参考资料
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。