Next.js 中间件实战:鉴权、重定向与 A/B 分流

📅 2026/7/12 10:58:35
Next.js 中间件实战:鉴权、重定向与 A/B 分流
Next.js 中间件实战:鉴权、重定向与 A/B 分流Next.js 的middleware.ts是个特别容易被误用的东西。它跑在每一个请求进入页面之前,而且运行在 Edge Runtime——这意味着你不能在里面连数据库、不能用 Node 的fs,还得小心它会拦截到静态资源。这篇用三个真实场景(登录鉴权、按地区重定向、A/B 分流)把中间件讲透,顺带把新手最常踩的matcher配置坑和 Edge Runtime 限制说清楚。中间件长什么样,它在哪一层跑在项目根目录(和app/同级)建一个middleware.ts:import{NextResponse}fromnext/serverimporttype{NextRequest}fromnext/serverexportfunctionmiddleware(request:NextRequest){console.log(请求路径:,request.nextUrl.pathname)returnNextResponse.next()// 放行,继续走到目标页面}它跑在请求到达页面/路由之前,在 CDN 边缘节点执行(Edge Runtime),比页面渲染更早。所以它适合做「所有请求都要先过一遍」的横切逻辑:鉴权、重定向、改 header、埋点。NextResponse.next()表示放行;你也可以返回redirect、rewrite或直接一个Response来中断。坑一:matcher 不配好,静态资源全被拦新手最常见的问题:写完中间件发现图片、CSS、_next里的 JS 全都走了一遍中间件,页面卡顿甚至白屏。因为默认情况下中间件匹配所有路径。必须用config.matcher精确圈定范围:exportconstconfig{matcher:[/* * 匹配除以下之外的所有路径: * - _next/static (构建产物) * - _next/image (图片优化) * - favicon.ico、public 下的静态文件 *//((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*),],}这条负向前瞻的正则是官方推荐写法。没有它,中间件会白白在每个静态资源请求上跑一次,既浪费又可能出 bug。记住:matcher 是中间件性能的第一道闸。场景一:登录鉴权与重定向最常见的用途:未登录用户访问/dashboard时踢回登录页,并带上原始地址方便登录后跳回。import{NextResponse}fromnext/serverimporttype{NextRequest}fromnext/serverexportfunctionmiddleware(request:NextRequest){consttokenrequest.cookies.get(session)?.valueconst{pathname}request.nextUrl// 受保护路由:没 token 就重定向到登录页if(pathname.startsWith(/dashboard)){if(!token){constloginUrlnewURL(/login,request.url)// 把用户原本想去的地址塞进 query,登录后可跳回loginUrl.searchParams.set(from,pathname)returnNextResponse.redirect(loginUrl)}}// 已登录还去登录页?直接送回 dashboardif(pathname/logintoken){returnNextResponse.redirect(newURL(/dashboard,request.url))}returnNextResponse.next()}exportconstconfig{matcher:[/dashboard/:path*,/login],}几个关键点:request.cookies.get()是中间件里读 cookie 的方式,和页面里的cookies()不同。构造 URL 一定要用new URL(path, request.url),把当前请求的 origin 带上,否则重定向会丢掉域名。⚠️不要在中间件里验证 JWT 签名的完整业务逻辑或查数据库。Edge Runtime 没有 Node 的 crypto/db 能力,而且中间件要快。这里只做「有没有 token」这种轻量判断;真正的权限校验放到页面或 API 层。中间件是「粗筛」,不是「终审」。场景二:按地区/语言重定向利用中间件能读到地理信息和请求头,做多语言或多地区分流:exportfunctionmiddleware(request:NextRequest){const{pathname}request.nextUrl// 已经带语言前缀就放行,避免无限重定向if(pathname.startsWith(/zh)||pathname.startsWith(/en)){returnNextResponse.next()}// 从 Accept-Language 头粗略判断语言constacceptrequest.headers.get(accept-language)||constlocaleaccept.startsWith(zh)?zh:en// 重写(rewrite)而不是重定向:URL 不变,内部指向带语言前缀的页面returnNextResponse.rewrite(newURL(/${locale}${pathname},request.url))}这里用了rewrite而不是redirect,区别很重要:redirect:浏览器地址栏会变,发生一次 302 跳转,用户能看到 URL 变化。rewrite:地址栏不变,服务端偷偷把请求指向另一个路径。用户以为访问的是/about,实际渲染的是/zh/about。多语言、灰度这类「对用户透明」的场景用rewrite;「地址确实该变」(如未登录踢到登录页)用redirect。场景三:A/B 分流(灰度实验)想让 50% 用户看新版首页、50% 看旧版,且同一用户每次访问结果一致。核心是:用 cookie 记住分桶,没分桶的随机分一次并写回。exportfunctionmiddleware(request:NextRequest){const{pathname}request.nextUrlif(pathname!/)returnNextResponse.next()// 读已有分桶letbucketrequest.cookies.get(ab-home)?.value// 没分过桶:随机分配 A/Bif(bucket!abucket!b){bucketMath.random()0.5?a:b}// B 组重写到新版首页;A 组走默认consturlbucketb?newURL(/home-v2,request.url):request.nextUrl.clone()constresNextResponse.rewrite(url)// 把分桶写进 cookie,保证用户后续访问稳定看到同一版本res.cookies.set(ab-home,bucket,{maxAge:60*60*24*30,// 30 天path:/,})returnres}exportconstconfig{matcher:[/],}要点:分桶结果必须落 cookie。否则每次刷新都重新Math.random(),同一个用户一会儿 A 一会儿 B,实验数据就废了。在响应对象上res.cookies.set()写回,而不是在 request 上。中间件里 request 的 cookie 是只读视图,写要写在返回的 response 上。A/B 分流放在中间件层的好处:分流发生在渲染之前,用户直接拿到对应版本的页面,不会出现「先闪一下 A 再跳 B」的抖动。Edge Runtime 的硬限制,别踩中间件跑在 Edge Runtime,和 Node 环境不是一回事,以下这些用不了:Node 内置模块:fs、net、child_process等一律不可用。大部分依赖 Node API 的 npm 包(比如某些完整的数据库驱动)。长时间运行的逻辑——中间件有执行时间上限,别在里面做重活。如果发现「本地能跑、部署到 Vercel 报错」,十有八九是在中间件里用了 Edge 不支持的 API。解决思路:把重逻辑挪到 Route Handler(app/api/.../route.ts)或 Server Component,中间件只留最轻量的判断和重定向。小结中间件跑在所有页面请求之前、Edge Runtime 上,适合鉴权粗筛、重定向、A/B 分流这类横切逻辑。config.matcher必须配,排除_next和静态资源,否则中间件会拖慢每一个资源请求。redirect改地址栏、rewrite不改:对用户透明的分流/多语言用 rewrite,该跳转的用 redirect。鉴权在中间件里只做「有没有 token」的粗筛,真正的权限校验放页面/API 层;别在 Edge Runtime 里查库或做重活。A/B 分流务必把分桶结果写进 cookie,并写在 response 上,保证同一用户体验一致。一句话记忆点:中间件是请求的「安检口」——它决定谁能进、往哪走,但别把它当成处理业务的「办公室」。