Next.js 中间件完全指南:从 Edge Runtime 到 Proxy 架构演进

从底层机制到生产实战,彻底搞懂 Next.js 中间件的工作原理与最佳实践。深度剖析 CVE-2025-29927 安全漏洞的教训,以及 Next.js 16 从 middleware.ts 到 proxy.ts 的架构迁移全攻略。

引言:中间件——你的应用的第一道防线

如果你已经读过我们之前关于 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 请求到来时,处理顺序大致是这样的:

  1. 中间件(Middleware) — 最先执行,在任何路由匹配之前
  2. 路由匹配 — 确定应该渲染哪个页面/布局
  3. 数据获取 — 服务器组件获取数据
  4. 渲染 — 生成 HTML 响应

换句话说,中间件有能力在请求碰到你的应用逻辑之前,就对它进行拦截、修改甚至直接响应。你可以把它想象成一个守在大门口的保安——检查来访者的身份、把人引导到正确的入口,甚至直接拒绝某些人入场。

1.2 运行环境:Edge Runtime

中间件默认跑在 Edge Runtime 上,不是标准的 Node.js 运行时。这个区别非常关键:

  • Edge Runtime 基于 Web API(Request、Response),能部署在全球分布的边缘节点上
  • 没有 Node.js 内置模块的访问权限(比如 fspathcrypto 的部分 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。这背后不是拍脑袋的决定,而是有很深的架构考量:

  1. 消除概念混淆:"中间件"这个词太容易让人联想到 Express.js 的 middleware 模式了。太多开发者把它当成万能逻辑层来用——数据库查询、复杂计算、完整的认证流程全往里塞。但 Next.js 的中间件本质上是一个网络代理,运行在应用前方,只该负责请求级别的轻量处理。
  2. 反映真实架构:"代理"(Proxy)更准确地描述了它的行为——它有网络边界,位于应用的前方,默认运行在 Edge Runtime 上,可以部署在离客户端更近的位置。这些特征用"代理"来概括再合适不过了。
  3. 引导正确的使用姿势:改名的一大目标,就是让开发者自觉地把复杂逻辑往 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)
skipMiddlewareUrlNormalizeskipProxyUrlNormalize
experimental.middlewarePrefetchexperimental.proxyPrefetch
experimental.middlewareClientMaxBodySizeexperimental.proxyClientMaxBodySize
experimental.externalMiddlewareRewritesResolveexperimental.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 等新特性正在重塑整个架构格局

中间件不是万能的,但放对了位置,它就是你应用里最高效的第一道防线。搞清楚它的能力边界,比盲目依赖它重要得多。希望这篇指南能帮你建立正确的心智模型,在真实项目中做出更靠谱的架构决策。

关于作者 Editorial Team

Our team of expert writers and editors.