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

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

Next.js中间件指南:middleware到proxy.ts迁移2026

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

如果你已经读过我们之前关于 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 等新特性正在重塑整个架构格局

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

关于作者 Marcus Okonkwo

Marcus picked up React in 2017 and has been writing it professionally ever since. Five years at Stripe on the Dashboard team, where he led the migration of the billing UI off a legacy webpack 4 setup onto Next.js with module federation for the embedded merchant components. He shipped the first internal RSC prototype there in late 2023, which mostly worked. Now he's a staff engineer at a logistics startup in Austin, owning the customer-facing Next.js app and the design system that backs it. He spends a disproportionate amount of time thinking about route group conventions, parallel routes for modal UX, and why next/image keeps surprising people on Cloudflare's image resizing. His writing favors annotated code over prose. Seven years of React, two of those deep in App Router land.