Skip to content

middleware.tsx

文件信息

  • 📄 原文件:01_middleware.tsx
  • 🔤 语言:TypeScript (Next.js / React)

中间件 (Middleware) 是 Next.js 提供的请求拦截机制。它在请求到达页面或 API 路由之前执行,可用于认证、重定向、请求改写、国际化等场景。

完整代码

tsx
/**
 * ============================================================
 *                    Next.js 中间件
 * ============================================================
 * 中间件 (Middleware) 是 Next.js 提供的请求拦截机制。
 * 它在请求到达页面或 API 路由之前执行,可用于
 * 认证、重定向、请求改写、国际化等场景。
 *
 * 适用版本:Next.js 14 / 15(App Router)
 * 文件位置:middleware.ts 放在项目根目录(与 app/ 同级)
 * ============================================================
 */

import { NextRequest, NextResponse } from 'next/server';
import { headers } from 'next/headers';

// ============================================================
//                    1. 中间件基础
// ============================================================

/**
 * 【什么是中间件】
 * - 中间件运行在 Edge Runtime(非 Node.js 运行时)
 * - 每个请求到达服务器后,先经过中间件处理
 * - 可以修改请求/响应的 headers、cookies
 * - 可以重定向 (redirect) 或重写 (rewrite) 请求
 *
 * 【文件位置】
 * ├── app/
 * ├── middleware.ts    ← 必须放在项目根目录
 * └── next.config.js
 *
 * 【执行顺序】
 * 请求 → middleware → 路由匹配 → 页面/API 渲染
 */

// --- 最简单的中间件 ---
export function middleware(request: NextRequest) {
    console.log('请求路径:', request.nextUrl.pathname);
    console.log('请求方法:', request.method);

    // NextResponse.next() 表示继续处理请求,不做拦截
    return NextResponse.next();
}

// --- NextRequest 常用属性 ---
function showNextRequestAPI(request: NextRequest) {
    const url = request.nextUrl;           // NextURL 对象
    const pathname = url.pathname;         // /dashboard/settings
    const searchParams = url.searchParams; // 查询参数
    const geo = request.geo;              // 地理位置(Vercel 可用)
    const ip = request.ip;               // 客户端 IP
    const cookieStore = request.cookies;  // Cookie 存储
}

// --- NextResponse 常用方法 ---
function showNextResponseAPI() {
    const next = NextResponse.next();                                    // 放行
    const redirect = NextResponse.redirect(new URL('/login', 'http://localhost:3000'));  // 重定向
    const rewrite = NextResponse.rewrite(new URL('/api/proxy', 'http://localhost:3000')); // 重写
    const json = NextResponse.json({ error: '未授权' }, { status: 401 });  // JSON 响应
}


// ============================================================
//                    2. 路由匹配
// ============================================================

/**
 * 【config.matcher 配置】
 * - 通过 matcher 指定中间件要拦截的路由
 * - 不配置 matcher 时,中间件会拦截所有请求
 * - 支持字符串、正则、数组等多种匹配模式
 */

// 方式1:单个路径匹配
export const config_single = {
    matcher: '/dashboard/:path*',
};

// 方式2:多个路径匹配
export const config_multiple = {
    matcher: ['/dashboard/:path*', '/admin/:path*', '/api/protected/:path*'],
};

// 方式3:排除特定路径(正则)
export const config_exclude = {
    matcher: ['/((?!api|_next/static|_next/image|favicon.ico|robots.txt).*)'],
};

// 方式4:在中间件内部条件判断
function middlewareWithConditional(request: NextRequest) {
    const pathname = request.nextUrl.pathname;

    // 跳过静态资源
    if (pathname.startsWith('/_next') || pathname.includes('.')) {
        return NextResponse.next();
    }

    if (pathname.startsWith('/dashboard')) return checkAuth(request);
    if (pathname.startsWith('/api/'))      return rateLimit(request);

    return NextResponse.next();
}

function checkAuth(req: NextRequest) { return NextResponse.next(); }
function rateLimit(req: NextRequest) { return NextResponse.next(); }


// ============================================================
//                    3. 重定向与重写
// ============================================================

/**
 * 【重定向 vs 重写】
 *
 * redirect:浏览器地址栏 URL 改变,返回 3xx
 *   → 适用于:登录跳转、旧 URL 迁移
 *
 * rewrite:浏览器地址栏 URL 不变,服务器内部转发
 *   → 适用于:A/B 测试、代理转发、多租户
 */

function redirectMiddleware(request: NextRequest) {
    const { pathname } = request.nextUrl;

    // 旧路径永久重定向
    if (pathname === '/old-blog') {
        return NextResponse.redirect(new URL('/blog', request.url), 301);
    }

    // 未登录用户重定向到登录页
    const token = request.cookies.get('session-token');
    if (!token && pathname.startsWith('/dashboard')) {
        const loginUrl = new URL('/login', request.url);
        loginUrl.searchParams.set('callbackUrl', pathname);
        return NextResponse.redirect(loginUrl);
    }

    return NextResponse.next();
}

function rewriteMiddleware(request: NextRequest) {
    const { pathname } = request.nextUrl;

    // A/B 测试:50% 的用户看到新版页面
    if (pathname === '/landing') {
        const bucket = Math.random() < 0.5 ? 'a' : 'b';
        return NextResponse.rewrite(new URL(`/landing/${bucket}`, request.url));
        // 用户地址栏仍然是 /landing,但实际渲染 /landing/a 或 /landing/b
    }

    // 多租户:根据子域名路由
    const hostname = request.headers.get('host') || '';
    const subdomain = hostname.split('.')[0];
    if (subdomain !== 'www' && subdomain !== 'localhost') {
        return NextResponse.rewrite(
            new URL(`/tenants/${subdomain}${pathname}`, request.url)
        );
    }

    return NextResponse.next();
}


// ============================================================
//                    4. 请求头操作
// ============================================================

/**
 * 【Headers 和 Cookies 操作】
 * - 中间件可以读取和修改请求/响应的 headers
 * - 可以设置、删除、读取 cookies
 * - 常用于传递上下文信息给 Server Components
 */

function headersMiddleware(request: NextRequest) {
    const requestHeaders = new Headers(request.headers);
    requestHeaders.set('x-pathname', request.nextUrl.pathname);
    requestHeaders.set('x-request-id', crypto.randomUUID());

    const response = NextResponse.next({
        request: { headers: requestHeaders },
    });

    // 安全相关响应头
    response.headers.set('X-Frame-Options', 'DENY');
    response.headers.set('X-Content-Type-Options', 'nosniff');
    return response;
}

function cookiesMiddleware(request: NextRequest) {
    const theme = request.cookies.get('theme');       // { name, value }
    const hasToken = request.cookies.has('token');     // 是否存在

    const response = NextResponse.next();

    // 设置 cookies(附带安全选项)
    response.cookies.set('visited', 'true', {
        httpOnly: true,        // 仅服务器端可访问
        secure: true,          // 仅 HTTPS
        sameSite: 'lax',       // CSRF 保护
        maxAge: 60 * 60 * 24,  // 1 天
        path: '/',
    });

    response.cookies.delete('old-cookie');  // 删除
    return response;
}

// 在 Server Component 中读取中间件设置的 headers
async function DashboardPage() {
    const headersList = await headers();
    const pathname = headersList.get('x-pathname');
    return <div>当前路径: {pathname}</div>;
}


// ============================================================
//                    5. 认证检查
// ============================================================

/**
 * 【中间件认证模式】
 * - 中间件是实现路由保护的理想位置
 * - 运行在 Edge Runtime,响应速度快
 *
 * 【注意事项】
 * - Edge Runtime 不支持 Node.js 原生模块
 * - 不能使用 jsonwebtoken 库,需用 jose(兼容 Edge)
 */

import { jwtVerify } from 'jose';

const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET || 'secret');
const protectedRoutes = ['/dashboard', '/profile', '/settings'];
const authRoutes = ['/login', '/register'];

async function authMiddleware(request: NextRequest) {
    const { pathname } = request.nextUrl;
    const token = request.cookies.get('auth-token')?.value;

    const isProtected = protectedRoutes.some(r => pathname.startsWith(r));
    const isAuthPage = authRoutes.some(r => pathname.startsWith(r));

    // 验证 JWT
    let isValid = false;
    let payload: any = null;
    if (token) {
        try {
            const verified = await jwtVerify(token, JWT_SECRET, { algorithms: ['HS256'] });
            payload = verified.payload;
            isValid = true;
        } catch { isValid = false; }
    }

    // 未登录 → 重定向登录页
    if (isProtected && !isValid) {
        const url = new URL('/login', request.url);
        url.searchParams.set('from', pathname);
        return NextResponse.redirect(url);
    }

    // 已登录 → 跳过登录/注册页
    if (isAuthPage && isValid) {
        return NextResponse.redirect(new URL('/dashboard', request.url));
    }

    // 将用户信息传递给后续路由
    if (isValid && payload) {
        const headers = new Headers(request.headers);
        headers.set('x-user-id', payload.userId as string);
        headers.set('x-user-role', payload.role as string);
        return NextResponse.next({ request: { headers } });
    }

    return NextResponse.next();
}

// --- 基于角色的访问控制 (RBAC) ---
async function rbacMiddleware(request: NextRequest) {
    const { pathname } = request.nextUrl;
    const userRole = request.headers.get('x-user-role');

    const roleRoutes: Record<string, string[]> = {
        '/api/users':   ['admin'],
        '/api/reports': ['admin', 'manager'],
        '/api/content': ['admin', 'manager', 'editor'],
    };

    for (const [route, roles] of Object.entries(roleRoutes)) {
        if (pathname.startsWith(route) && (!userRole || !roles.includes(userRole))) {
            return NextResponse.json({ error: '权限不足' }, { status: 403 });
        }
    }
    return NextResponse.next();
}


// ============================================================
//                    6. 国际化路由
// ============================================================

/**
 * 【中间件实现 i18n】
 * - 检测用户首选语言(Accept-Language 头)
 * - 根据 cookie 或路径前缀判断当前语言
 * - 自动重定向到正确的语言路径
 *
 * 【URL 结构】
 * /zh/about → 中文    /en/about → 英文    /about → 自动检测
 */

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

function getPreferredLocale(request: NextRequest): string {
    const acceptLang = request.headers.get('Accept-Language') || '';
    // 解析 zh-CN,zh;q=0.9,en;q=0.8 格式
    const preferred = acceptLang.split(',').map(lang => {
        const [code, q] = lang.trim().split(';q=');
        return { code: code.split('-')[0].toLowerCase(), quality: q ? parseFloat(q) : 1.0 };
    }).sort((a, b) => b.quality - a.quality);

    for (const { code } of preferred) {
        if (locales.includes(code)) return code;
    }
    return defaultLocale;
}

function i18nMiddleware(request: NextRequest) {
    const { pathname } = request.nextUrl;

    // 已包含语言前缀 → 放行
    const hasLocale = locales.some(
        l => pathname.startsWith(`/${l}/`) || pathname === `/${l}`
    );
    if (hasLocale) return NextResponse.next();

    // 优先 cookie,其次 Accept-Language
    const cookieLocale = request.cookies.get('NEXT_LOCALE')?.value;
    const locale = (cookieLocale && locales.includes(cookieLocale))
        ? cookieLocale
        : getPreferredLocale(request);

    // /about → /zh/about
    const newUrl = new URL(`/${locale}${pathname}`, request.url);
    newUrl.search = request.nextUrl.search;

    const response = NextResponse.redirect(newUrl);
    response.cookies.set('NEXT_LOCALE', locale, { maxAge: 60 * 60 * 24 * 365 });
    return response;
}

// --- 语言切换处理 ---
// 点击语言切换按钮 → /set-locale?locale=en&redirect=/about
function handleLocaleSwitch(request: NextRequest) {
    const { pathname, searchParams } = request.nextUrl;

    if (pathname === '/set-locale') {
        const newLocale = searchParams.get('locale');
        const redirect = searchParams.get('redirect') || '/';

        if (newLocale && locales.includes(newLocale)) {
            const response = NextResponse.redirect(
                new URL(`/${newLocale}${redirect}`, request.url)
            );
            response.cookies.set('NEXT_LOCALE', newLocale, { maxAge: 60 * 60 * 24 * 365 });
            return response;
        }
    }
    return i18nMiddleware(request);
}


// ============================================================
//                    7. 最佳实践
// ============================================================

/**
 * 【中间件最佳实践】
 *
 * ✅ 推荐做法:
 * 1. 使用 config.matcher 精确限制中间件作用范围
 * 2. 保持中间件轻量 — Edge Runtime 有执行时间限制
 * 3. 使用 jose 库替代 jsonwebtoken(兼容 Edge Runtime)
 * 4. 通过 headers 传递上下文信息给 Server Components
 * 5. 合理使用 redirect 和 rewrite 的场景区分
 * 6. 为安全 cookies 设置 httpOnly、secure、sameSite
 * 7. 中间件只做「门卫」工作:认证、重定向、头信息
 *
 * ❌ 避免做法:
 * 1. 在中间件中进行复杂的数据库查询 → 使用 API 路由
 * 2. 不配置 matcher 导致静态资源也被拦截 → 性能浪费
 * 3. 在中间件中使用 Node.js 专有模块 → Edge Runtime 不支持
 * 4. 忽略 Token 过期处理 → 用户体验差
 * 5. 在中间件中渲染 UI → 中间件只负责请求层逻辑
 */

// 推荐的 config.matcher(排除静态资源)
export const config = {
    matcher: [
        '/((?!api|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico)$).*)',
    ],
};

💬 讨论

使用 GitHub 账号登录后即可参与讨论

基于 MIT 许可发布