Skip to content

Next.js 中间件

中间件是 Next.js 的强大功能,允许你在请求完成之前运行代码。本章将详细介绍如何使用中间件处理认证、重定向、国际化等常见场景。

中间件基础

创建中间件

在项目根目录创建 middleware.ts 文件:

typescript
// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

export function middleware(request: NextRequest) {
  // 中间件逻辑
  console.log('中间件执行:', request.nextUrl.pathname)
  
  // 继续处理请求
  return NextResponse.next()
}

// 配置匹配路径
export const config = {
  matcher: [
    /*
     * 匹配所有路径除了:
     * - api (API routes)
     * - _next/static (static files)
     * - _next/image (image optimization files)
     * - favicon.ico (favicon file)
     */
    '/((?!api|_next/static|_next/image|favicon.ico).*)',
  ],
}

基本响应类型

typescript
// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

export function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl

  // 1. 继续处理请求
  if (pathname === '/continue') {
    return NextResponse.next()
  }

  // 2. 重定向
  if (pathname === '/old-page') {
    return NextResponse.redirect(new URL('/new-page', request.url))
  }

  // 3. 重写 URL
  if (pathname === '/rewrite') {
    return NextResponse.rewrite(new URL('/internal-page', request.url))
  }

  // 4. 返回响应
  if (pathname === '/api/blocked') {
    return new NextResponse('Access Denied', { status: 403 })
  }

  return NextResponse.next()
}

认证中间件

JWT 认证

typescript
// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { jwtVerify } from 'jose'

const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET!)

async function verifyToken(token: string) {
  try {
    const { payload } = await jwtVerify(token, JWT_SECRET)
    return payload
  } catch (error) {
    return null
  }
}

export async function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl

  // 需要认证的路径
  const protectedPaths = ['/dashboard', '/profile', '/admin']
  const isProtectedPath = protectedPaths.some(path => 
    pathname.startsWith(path)
  )

  if (isProtectedPath) {
    const token = request.cookies.get('token')?.value

    if (!token) {
      // 重定向到登录页
      const loginUrl = new URL('/login', request.url)
      loginUrl.searchParams.set('redirect', pathname)
      return NextResponse.redirect(loginUrl)
    }

    const user = await verifyToken(token)
    if (!user) {
      // Token 无效,清除 cookie 并重定向
      const response = NextResponse.redirect(new URL('/login', request.url))
      response.cookies.delete('token')
      return response
    }

    // 将用户信息添加到请求头
    const response = NextResponse.next()
    response.headers.set('x-user-id', user.sub as string)
    response.headers.set('x-user-role', user.role as string)
    return response
  }

  return NextResponse.next()
}

export const config = {
  matcher: ['/dashboard/:path*', '/profile/:path*', '/admin/:path*']
}

基于角色的访问控制

typescript
// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

interface User {
  id: string
  role: 'admin' | 'user' | 'moderator'
}

async function getUserFromToken(token: string): Promise<User | null> {
  // 实现 token 验证逻辑
  try {
    const response = await fetch(`${process.env.API_URL}/auth/verify`, {
      headers: { Authorization: `Bearer ${token}` }
    })
    
    if (response.ok) {
      return await response.json()
    }
  } catch (error) {
    console.error('Token verification failed:', error)
  }
  
  return null
}

export async function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl
  
  // 定义路径权限
  const pathPermissions = {
    '/admin': ['admin'],
    '/moderator': ['admin', 'moderator'],
    '/dashboard': ['admin', 'moderator', 'user'],
  }

  const requiredRoles = Object.entries(pathPermissions).find(([path]) =>
    pathname.startsWith(path)
  )?.[1]

  if (requiredRoles) {
    const token = request.cookies.get('auth-token')?.value

    if (!token) {
      return NextResponse.redirect(new URL('/login', request.url))
    }

    const user = await getUserFromToken(token)
    
    if (!user || !requiredRoles.includes(user.role)) {
      return NextResponse.redirect(new URL('/unauthorized', request.url))
    }

    // 添加用户信息到请求头
    const response = NextResponse.next()
    response.headers.set('x-user-id', user.id)
    response.headers.set('x-user-role', user.role)
    return response
  }

  return NextResponse.next()
}

国际化中间件

语言检测和重定向

typescript
// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

const locales = ['en', 'zh', 'ja', 'ko']
const defaultLocale = 'en'

function getLocale(request: NextRequest): string {
  // 1. 检查 URL 路径中的语言
  const pathname = request.nextUrl.pathname
  const pathnameLocale = locales.find(
    locale => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
  )
  
  if (pathnameLocale) return pathnameLocale

  // 2. 检查 cookie
  const cookieLocale = request.cookies.get('locale')?.value
  if (cookieLocale && locales.includes(cookieLocale)) {
    return cookieLocale
  }

  // 3. 检查 Accept-Language 头
  const acceptLanguage = request.headers.get('accept-language')
  if (acceptLanguage) {
    const browserLocale = acceptLanguage
      .split(',')[0]
      .split('-')[0]
    
    if (locales.includes(browserLocale)) {
      return browserLocale
    }
  }

  return defaultLocale
}

export function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl

  // 检查路径是否已包含语言前缀
  const pathnameHasLocale = locales.some(
    locale => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
  )

  if (!pathnameHasLocale) {
    const locale = getLocale(request)
    const newUrl = new URL(`/${locale}${pathname}`, request.url)
    
    const response = NextResponse.redirect(newUrl)
    response.cookies.set('locale', locale, { maxAge: 60 * 60 * 24 * 365 })
    return response
  }

  return NextResponse.next()
}

export const config = {
  matcher: [
    '/((?!api|_next/static|_next/image|favicon.ico).*)',
  ],
}

语言切换

typescript
// components/LanguageSwitcher.tsx
'use client'

import { useRouter, usePathname } from 'next/navigation'
import { useState } from 'react'

const languages = {
  en: 'English',
  zh: '中文',
  ja: '日本語',
  ko: '한국어',
}

export default function LanguageSwitcher() {
  const router = useRouter()
  const pathname = usePathname()
  const [currentLocale, setCurrentLocale] = useState('en')

  const switchLanguage = (newLocale: string) => {
    // 移除当前语言前缀
    const pathWithoutLocale = pathname.replace(/^\/[a-z]{2}/, '')
    
    // 添加新语言前缀
    const newPath = `/${newLocale}${pathWithoutLocale}`
    
    // 设置 cookie
    document.cookie = `locale=${newLocale}; path=/; max-age=${60 * 60 * 24 * 365}`
    
    setCurrentLocale(newLocale)
    router.push(newPath)
  }

  return (
    <select
      value={currentLocale}
      onChange={(e) => switchLanguage(e.target.value)}
      className="border rounded px-2 py-1"
    >
      {Object.entries(languages).map(([code, name]) => (
        <option key={code} value={code}>
          {name}
        </option>
      ))}
    </select>
  )
}

安全中间件

CSRF 保护

typescript
// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import crypto from 'crypto'

function generateCSRFToken(): string {
  return crypto.randomBytes(32).toString('hex')
}

function verifyCSRFToken(token: string, sessionToken: string): boolean {
  return token === sessionToken
}

export function middleware(request: NextRequest) {
  const { method, nextUrl } = request

  // 只对状态改变的请求进行 CSRF 检查
  if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(method)) {
    const csrfToken = request.headers.get('x-csrf-token')
    const sessionToken = request.cookies.get('csrf-token')?.value

    if (!csrfToken || !sessionToken || !verifyCSRFToken(csrfToken, sessionToken)) {
      return new NextResponse('CSRF token mismatch', { status: 403 })
    }
  }

  // 为 GET 请求生成 CSRF token
  if (method === 'GET' && !request.cookies.get('csrf-token')) {
    const token = generateCSRFToken()
    const response = NextResponse.next()
    response.cookies.set('csrf-token', token, {
      httpOnly: true,
      secure: process.env.NODE_ENV === 'production',
      sameSite: 'strict',
    })
    return response
  }

  return NextResponse.next()
}

速率限制

typescript
// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

// 简单的内存存储(生产环境建议使用 Redis)
const rateLimitMap = new Map<string, { count: number; resetTime: number }>()

function getRateLimitKey(request: NextRequest): string {
  // 使用 IP 地址作为限制键
  const forwarded = request.headers.get('x-forwarded-for')
  const ip = forwarded ? forwarded.split(',')[0] : request.ip || 'unknown'
  return ip
}

function isRateLimited(key: string, limit: number, windowMs: number): boolean {
  const now = Date.now()
  const record = rateLimitMap.get(key)

  if (!record || now > record.resetTime) {
    // 重置或创建新记录
    rateLimitMap.set(key, {
      count: 1,
      resetTime: now + windowMs
    })
    return false
  }

  if (record.count >= limit) {
    return true
  }

  record.count++
  return false
}

export function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl

  // 对 API 路由应用速率限制
  if (pathname.startsWith('/api/')) {
    const key = getRateLimitKey(request)
    const limit = 100 // 每分钟 100 次请求
    const windowMs = 60 * 1000 // 1 分钟

    if (isRateLimited(key, limit, windowMs)) {
      return new NextResponse('Too Many Requests', {
        status: 429,
        headers: {
          'Retry-After': '60'
        }
      })
    }
  }

  return NextResponse.next()
}

重定向和重写

动态重定向

typescript
// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

// 重定向规则
const redirectRules = [
  { from: '/old-blog/:slug*', to: '/blog/:slug*' },
  { from: '/products/:id', to: '/shop/products/:id' },
  { from: '/user/:id', to: '/profile/:id' },
]

function applyRedirectRules(pathname: string): string | null {
  for (const rule of redirectRules) {
    const fromPattern = rule.from.replace(/:\w+\*/g, '(.*)').replace(/:\w+/g, '([^/]+)')
    const regex = new RegExp(`^${fromPattern}$`)
    const match = pathname.match(regex)

    if (match) {
      let to = rule.to
      match.slice(1).forEach((param, index) => {
        to = to.replace(/:\w+\*?/, param)
      })
      return to
    }
  }

  return null
}

export function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl

  // 应用重定向规则
  const redirectTo = applyRedirectRules(pathname)
  if (redirectTo) {
    return NextResponse.redirect(new URL(redirectTo, request.url))
  }

  return NextResponse.next()
}

A/B 测试

typescript
// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

function getVariant(request: NextRequest): 'A' | 'B' {
  // 检查现有 cookie
  const existingVariant = request.cookies.get('ab-test-variant')?.value
  if (existingVariant === 'A' || existingVariant === 'B') {
    return existingVariant
  }

  // 随机分配变体
  return Math.random() < 0.5 ? 'A' : 'B'
}

export function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl

  // 只对首页进行 A/B 测试
  if (pathname === '/') {
    const variant = getVariant(request)
    
    let response: NextResponse

    if (variant === 'B') {
      // 重写到 B 版本页面
      response = NextResponse.rewrite(new URL('/home-variant-b', request.url))
    } else {
      response = NextResponse.next()
    }

    // 设置变体 cookie
    response.cookies.set('ab-test-variant', variant, {
      maxAge: 60 * 60 * 24 * 30, // 30 天
    })

    return response
  }

  return NextResponse.next()
}

日志和监控

请求日志

typescript
// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

interface RequestLog {
  timestamp: string
  method: string
  url: string
  userAgent: string
  ip: string
  duration?: number
}

function logRequest(log: RequestLog) {
  // 发送到日志服务
  console.log(JSON.stringify(log))
  
  // 或发送到外部服务
  // fetch('/api/logs', {
  //   method: 'POST',
  //   body: JSON.stringify(log)
  // })
}

export function middleware(request: NextRequest) {
  const startTime = Date.now()
  
  const log: RequestLog = {
    timestamp: new Date().toISOString(),
    method: request.method,
    url: request.url,
    userAgent: request.headers.get('user-agent') || '',
    ip: request.headers.get('x-forwarded-for') || request.ip || 'unknown',
  }

  const response = NextResponse.next()

  // 添加响应时间
  response.headers.set('x-response-time', `${Date.now() - startTime}ms`)

  // 异步记录日志
  Promise.resolve().then(() => {
    log.duration = Date.now() - startTime
    logRequest(log)
  })

  return response
}

性能监控

typescript
// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

const performanceMetrics = new Map<string, number[]>()

function recordMetric(path: string, duration: number) {
  if (!performanceMetrics.has(path)) {
    performanceMetrics.set(path, [])
  }
  
  const metrics = performanceMetrics.get(path)!
  metrics.push(duration)
  
  // 只保留最近 100 个记录
  if (metrics.length > 100) {
    metrics.shift()
  }
}

function getAverageResponseTime(path: string): number {
  const metrics = performanceMetrics.get(path)
  if (!metrics || metrics.length === 0) return 0
  
  const sum = metrics.reduce((a, b) => a + b, 0)
  return sum / metrics.length
}

export function middleware(request: NextRequest) {
  const startTime = Date.now()
  const { pathname } = request.nextUrl

  const response = NextResponse.next()

  // 记录响应时间
  Promise.resolve().then(() => {
    const duration = Date.now() - startTime
    recordMetric(pathname, duration)
    
    // 如果响应时间过长,发送警告
    if (duration > 1000) {
      console.warn(`Slow response: ${pathname} took ${duration}ms`)
    }
  })

  return response
}

错误处理

中间件错误处理

typescript
// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

export function middleware(request: NextRequest) {
  try {
    // 中间件逻辑
    const { pathname } = request.nextUrl

    // 可能抛出错误的操作
    if (pathname === '/error-test') {
      throw new Error('测试错误')
    }

    return NextResponse.next()
  } catch (error) {
    console.error('Middleware error:', error)
    
    // 返回错误响应
    return new NextResponse('Internal Server Error', {
      status: 500,
      headers: {
        'Content-Type': 'text/plain',
      },
    })
  }
}

测试中间件

单元测试

typescript
// __tests__/middleware.test.ts
import { NextRequest } from 'next/server'
import { middleware } from '../middleware'

describe('Middleware', () => {
  it('should redirect unauthenticated users', 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: 'token=valid-jwt-token'
      }
    })
    
    const response = await middleware(request)
    expect(response.status).toBe(200)
  })
})

最佳实践

1. 性能优化

typescript
// ✅ 好的做法 - 早期返回
export function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl
  
  // 跳过静态资源
  if (pathname.startsWith('/_next/') || pathname.includes('.')) {
    return NextResponse.next()
  }
  
  // 其他逻辑...
}

// ❌ 避免 - 复杂的同步操作
export function middleware(request: NextRequest) {
  // 避免复杂的计算
  const result = heavyComputation()
  return NextResponse.next()
}

2. 错误处理

typescript
// ✅ 好的做法
export async function middleware(request: NextRequest) {
  try {
    const user = await verifyToken(token)
    return NextResponse.next()
  } catch (error) {
    console.error('Auth error:', error)
    return NextResponse.redirect(new URL('/login', request.url))
  }
}

3. 匹配器配置

typescript
// ✅ 好的做法 - 精确匹配
export const config = {
  matcher: [
    '/dashboard/:path*',
    '/api/protected/:path*',
    '/((?!_next/static|_next/image|favicon.ico).*)',
  ],
}

// ❌ 避免 - 过于宽泛的匹配
export const config = {
  matcher: '/:path*', // 会匹配所有路径
}

总结

Next.js 中间件的关键特性:

  • 请求拦截 - 在请求到达页面前执行逻辑
  • 灵活响应 - 重定向、重写、修改响应
  • 性能优化 - 边缘计算,减少服务器负载
  • 安全增强 - 认证、授权、CSRF 保护
  • 监控分析 - 请求日志、性能指标

合理使用中间件可以提升应用的安全性、性能和用户体验。

本站内容仅供学习和研究使用。