Vercel Middleware 实战指南:A/B 测试、认证拦截与请求重写全解

详解 Next.js Middleware 在 Vercel 上的全部实战技巧:请求拦截与重写、A/B 测试路由、JWT 认证中间件、多语言国际化、地理位置定制、与 Edge Functions 协同。含生产级代码示例和性能优化建议。

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 的区别

维度MiddlewareEdge 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();
}
// 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 测试

// 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。替代方案:

  1. 用 JWT/Session Cookie 做无状态验证(推荐)
  2. 通过 HTTP API 查询外部认证服务(如 Auth0、Clerk 的 API)
  3. 用 Edge 兼容的 KV 存储(Upstash Redis、Vercel KV)保存会话信息

为什么我的 Middleware 没有被执行?

排查步骤:

  1. 确认 middleware.ts 放在项目根目录(与 app/package.json 同级)
  2. 确认 config.matcher 匹配了你的目标路径
  3. 确认没有被 npm run dev 的缓存影响,尝试重启开发服务器
  4. 在 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 匹配范围——排除静态资源可以大幅减少调用次数。

相关阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「saas」更多文章