发布日期

第 31 讲:国际化、rewrites、basePath 与 Proxy

国际化子路径、rewrites 重写规则、basePath 配置与反向代理集成

第 15 讲讲过请求一生从 router-server 进 app-render,第 28 讲讲过 routes-manifest.json 的结构。本讲沿着同一条线深挖:next.config.js 里的 rewrites / redirects / headers / basePath / i18n 是怎么在 build 时编译成 routes-manifest,又是怎么在请求时被 resolveRoutes 按特定顺序 match 的。掌握这些细节,你才能解释生产中 "为什么我的 rewrite 没生效"、"为什么 i18n 路由出 404"、"为什么 CDN 后挂 basePath 后 chunk 404"。

学习目标

  1. 看懂 resolveRoutes 的 routing pipeline 8 个阶段(headers → redirects → middleware → beforeFiles → check_fs → afterFiles → check:true → fallback)。
  2. 理解 3 类 rewrite 的差异:beforeFiles / afterFiles / fallback。
  3. 用 i18n + domains 配置出"中文站走 .cn,英文站走 .com"的多域名站点。
  4. 理解 basePath / assetPrefix 在 CDN / 反向代理后的作用,避免 chunk 404。
  5. 排查 5 类常见 routing 问题:rewrite 没生效、locale prefix 丢失、headers 加错位置、middleware 与 rewrite 交互混乱、SSR resource path 错。

一、3 个能力的边界

配置行为用户感知 URL客户端跳转
redirectsserver 返回 30x 头改变
rewritesserver 把 path 改写后再 match不变
headersserver 在响应里加自定义 header不变
basePath整个站挂到 /basePath/*改变/
i18n加 locale prefix;做 locale 检测改变/

rewrite 是 URL 不变 + 实际渲染另一个 page。最常见用法:

  • /blog/iphone ← rewrite → /products/iphone(隐藏内部路径)
  • /api/* ← rewrite → 外部服务(反向代理)

redirect 是 URL 改变 + 浏览器跳转。常见用法:

  • 老 URL 迁移:/old-blog/:slug/blog/:slug 301

二、Routing pipeline 总览

115:packages/next/src/server/lib/router-utils/resolve-routes.ts
const calculateRoutes = () => {
  return [
    { match: () => ({}), name: 'middleware_next_data' },
    ...(opts.minimalMode ? [] : fsChecker.headers),
    ...(opts.minimalMode ? [] : fsChecker.redirects),
    { match: () => ({}), name: 'middleware' },
    ...(opts.minimalMode ? [] : fsChecker.rewrites.beforeFiles),
    { match: () => ({}), name: 'before_files_end' },
    { match: () => ({}), name: 'check_fs' },
    ...(opts.minimalMode ? [] : fsChecker.rewrites.afterFiles),
    {
      check: true,
      match: () => ({}),
      name: 'after files check: true',
    },
    ...(opts.minimalMode ? [] : fsChecker.rewrites.fallback),
  ]
}

每个请求依次过这 8 个阶段:

┌──────────────────────────┐
1. middleware_next_data  │  RSC _next/data 路径预处理
├──────────────────────────┤
2. headers               │  匹配则在 response 加 header
├──────────────────────────┤
3. redirects             │  匹配则 302/308,结束
├──────────────────────────┤
4. middleware            │  跑 middleware.ts,可改 URL/resp
├──────────────────────────┤
5. rewrites.beforeFiles  │  在 fs 检查之前 rewrite,覆盖 page
├──────────────────────────┤
6. check_fs              │  匹配 .next/server/app/ 里的 page
├──────────────────────────┤
7. rewrites.afterFiles   │  page 没匹配上才 rewrite
├──────────────────────────┤
8. rewrites.fallback     │  afterFiles 都没匹配上才 rewrite
└──────────────────────────┘

理解 "match 与 fs check 的相对位置" 是排查 rewrite 失效的关键。

三、3 类 rewrite 的真实用法

next.config.js

module.exports = {
  async rewrites() {
    return {
      // 1. beforeFiles:在 fs 检查之前 rewrite,可以覆盖已存在的 page
      beforeFiles: [
        { source: "/about", destination: "/maintenance" },
        // /api/v2/:path* 走外部服务
        {
          source: "/api/v2/:path*",
          destination: "https://api.example.com/:path*",
        },
      ],

      // 2. afterFiles:page 没匹配上才 rewrite
      afterFiles: [{ source: "/blog/:slug", destination: "/posts/:slug" }],

      // 3. fallback:所有都没匹配上才 rewrite(兜底)
      fallback: [
        { source: "/:path*", destination: "https://legacy.example.com/:path*" },
      ],
    };
  },
};

实战场景:

需求用哪种
维护期把所有 /about 重定向到 /maintenancebeforeFiles
/blog/* 实际是 /posts/*(重命名)afterFiles
Next.js 迁移:旧域名所有 URL 兜底 proxy 到老服务fallback
API 路由代理外部beforeFiles(防止本地 /api/* 冲突)

注意点:

  • destination外部 URL 时(http://...),Next.js 会做 HTTP proxy;是内部 path 时(/...),改写后继续 routing
  • :slug / :path* 使用 path-to-regexp 语法,与 React Router 一致
  • rewrite 不影响浏览器 URL,但影响 page 接收到的 params:rewrite 到 /posts/:slug 后 page 能拿到 slug

四、i18n 路由实现

245:packages/next/src/server/lib/router-utils/resolve-routes.ts
if (config.i18n) {
  const hasBasePath = pathHasPrefix(normalizedPath, config.basePath)
  if (config.basePath && pathHasPrefix(normalizedPath, config.basePath)) {
    normalizedPath = removePathPrefix(normalizedPath, config.basePath)
  }

  const localePathResult = normalizeLocalePath(normalizedPath, config.i18n.locales)
  const domainLocale = detectDomainLocale(
    config.i18n.domains,
    req.headers.host?.split(':')[0]
  )
  defaultLocale = domainLocale?.defaultLocale || config.i18n.defaultLocale
}

next.config.js

module.exports = {
  i18n: {
    locales: ["zh-CN", "en-US", "ja-JP"],
    defaultLocale: "zh-CN",
    localeDetection: true, // 根据 Accept-Language 自动检测
    domains: [
      { domain: "example.cn", defaultLocale: "zh-CN" },
      { domain: "example.com", defaultLocale: "en-US" },
    ],
  },
};

路径处理逻辑

请求 example.com/products/iphone
  ↓ basePath strip(如果有)
请求 /products/iphone
  ↓ normalizeLocalePath
{ pathname: '/products/iphone', detectedLocale: undefined }
  ↓ detectDomainLocale → domain=example.com → defaultLocale=en-US
最终 locale = 'en-US', pathname = '/products/iphone'
  ↓ 渲染 page,注入 router.locale = 'en-US'

Path locale prefix(无 domain 模式):

请求 /zh-CN/products/iphone
  ↓ normalizeLocalePath
{ pathname: '/products/iphone', detectedLocale: 'zh-CN' }

注意

  • App Router 没有内置 i18n!上面的 i18n 配置只对 Pages Router 生效
  • App Router 推荐用 [lang] 动态 segment + middleware.ts 自己处理
  • next-intl / next-i18next 等社区库在 App Router 下都是基于 [lang]

App Router 下的 i18n 模式

app/
  [lang]/
    layout.tsx     ← 读 params.lang,注入 locale context
    page.tsx
    products/[slug]/page.tsx
middleware.ts      ← 检测 Accept-Language,rewrite 到对应 lang

middleware.ts

import { NextRequest, NextResponse } from "next/server";

const locales = ["zh-CN", "en-US"];
const defaultLocale = "zh-CN";

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

  const hasLocale = locales.some(
    (l) => pathname.startsWith(`/${l}/`) || pathname === `/${l}`,
  );

  if (hasLocale) return;

  // 从 Accept-Language 检测
  const acceptLang = request.headers.get("accept-language") ?? "";
  const detected = locales.find((l) => acceptLang.startsWith(l.split("-")[0]));
  const locale = detected ?? defaultLocale;

  // rewrite 而非 redirect,URL 不变
  return NextResponse.rewrite(new URL(`/${locale}${pathname}`, request.url));
}

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

五、basePath 与 assetPrefix

module.exports = {
  basePath: "/blog", // 整个 app 挂到 /blog/* 下
  assetPrefix: "https://cdn.example.com/blog", // 静态资源走 CDN
};

效果:

  • 用户访问 https://example.com/blog/posts/iphone → 命中 app/posts/[slug]/page.tsx
  • <Link href="/posts/iphone"> 自动渲染为 /blog/posts/iphone
  • _next/static/chunks/... 自动拼成 https://cdn.example.com/blog/_next/static/chunks/...

使用场景

  • 公司主站 example.com 用别的技术栈,Next.js 部署到 example.com/blog
  • 静态资源用独立 CDN 域名(分离主站和 CDN 流量)

常见坑

现象原因
chunk 加载 404_next/static 路径少 basePath;客户端用 <Link> 没问题,但手写 <a>useRouter().basePath
API 路由 404API 路由也要带 basePath:fetch('/blog/api/...')
middleware 处理时漏 basePathreq.nextUrl.pathname 已 strip basePath,但 req.url 没有
dev 模式正常,prod 404assetPrefix 在 dev 模式被忽略

router-server 内部处理 basePath:

  • request → strip basePath → 得到内部 pathname
  • response → 资源 URL 拼回 assetPrefix / basePath

六、Headers 与 CSP

async headers() {
  return [
    {
      source: '/(.*)',
      headers: [
        { key: 'X-Frame-Options', value: 'DENY' },
        { key: 'X-Content-Type-Options', value: 'nosniff' },
        {
          key: 'Strict-Transport-Security',
          value: 'max-age=31536000; includeSubDomains; preload'
        },
      ],
    },
    {
      source: '/_next/static/(.*)',
      headers: [
        { key: 'Cache-Control', value: 'public, max-age=31536000, immutable' },
      ],
    },
  ]
}

headers 阶段在 routing pipeline 第 2 位,意味着:

  • redirect 之前(所以 redirect 不会带这些 header)
  • middleware 之前(middleware 可以 override)

CSP nonce 必须用 middleware 而非 headers,因为每次请求要不同 nonce:

import { NextResponse } from "next/server";

export function middleware(request: NextRequest) {
  const nonce = crypto.randomUUID();
  const csp = `script-src 'self' 'nonce-${nonce}'; object-src 'none'`;

  const response = NextResponse.next({
    request: {
      headers: new Headers({
        ...Object.fromEntries(request.headers),
        "x-nonce": nonce,
      }),
    },
  });
  response.headers.set("content-security-policy", csp);
  return response;
}

page 里通过 headers() 读:

import { headers } from "next/headers";

export default async function Page() {
  const nonce = (await headers()).get("x-nonce");
  return <script nonce={nonce!}>...</script>;
}

七、与 CDN / 反向代理的协作

完整请求链:

浏览器
DNSCDN (CloudFront/Cloudflare)
  ↓ cache miss
反向代理 (Nginx / ALB)
Next.js (node server, 多 pod)

每一层都要正确处理:

CDN 层

  • _next/static/* 长 cache(immutable)
  • _next/image 短 cache(按图片源 ETag)
  • HTML 短 cache 或 no-cache(让 Next.js 控制 ISR)

CloudFront behaviors 示例:

Path PatternTTLHeaders Forwarded
/_next/static/*1 yearnone
/_next/image*1 dayAccept
/*60sAccept-Language, Cookie (selective)

Nginx 层

upstream nextjs {
  server next-1:3000;
  server next-2:3000;
}

server {
  listen 80;

  # 不缓存 SSR/ISR HTML,让 Next.js 控
  location / {
    proxy_pass http://nextjs;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_http_version 1.1;
    proxy_set_header Connection "";
    proxy_read_timeout 60s;
  }

  # 长 cache 静态资源
  location /_next/static/ {
    proxy_pass http://nextjs;
    expires 1y;
    add_header Cache-Control "public, immutable";
  }
}

注意:

  • proxy_set_header Host 必须传,否则 req.headers.host
  • X-Forwarded-Proto 影响 request.nextUrl.protocol
  • HTTP/1.1 + Connection: "" 启用 keep-alive

八、排障实战

案例 1:rewrite 不生效

症状:配了 { source: '/blog/:slug', destination: '/posts/:slug' },访问 /blog/foo 出 404。

排查

  1. 检查 rewrite 是 beforeFiles 还是 afterFiles?
  2. 是否同时有 /blog/[slug]/page.tsx?afterFiles 会被它截断
  3. dev 模式 routes-manifest 是否最新?重启 dev server
  4. 检查 build log 输出的路由表

案例 2:i18n locale 总是 default

症状:访问 /en-US/products 但页面文案是中文。

排查

  1. App Router 不支持 next.config.jsi18n,必须用 [lang] segment + middleware
  2. middleware matcher 是否排除了 /_next/static,否则 static asset 也走 i18n 检测
  3. params.lang 是否被 page 实际读取并传递

案例 3:basePath 下 chunk 404

症状:配 basePath: '/blog',访问 /blog/,控制台 chunks/page-xxx.js 404。

排查

  1. CDN 是否把 /blog/_next/static/* 配置了正确的源?
  2. assetPrefix 是否带了 protocol?应该是 https://cdn.example.com/blog
  3. dev 模式正常 prod 异常 → assetPrefix dev 被忽略,prod 才生效
  4. next start 直接跑能否正确加载?如果可以,问题在反向代理

案例 4:middleware 改了 path 但 page 404

症状:middleware 里 NextResponse.rewrite(new URL('/foo', req.url)),但 page 拿不到。

排查

  1. middleware 的 matcher 是否覆盖了原 path?
  2. rewrite 目标 page 是否存在?
  3. middleware 跑在 edge runtime,使用 Node-only API 会报错

案例 5:reverse proxy 后 SSR URL 错

症状:用户访问 https://example.com/foo,但服务端 headers().get('host') 是内部 IP。

排查

  1. Nginx 加 proxy_set_header Host $host
  2. proxy_set_header X-Forwarded-Host $host
  3. Next.js 默认信任 X-Forwarded-*(在 experimental.trustHostHeader
  4. 在生产环境用 request.headers.get('host') 而非 req.url

九、配套 fixture

fixtures/lecture-31/ 提供:

  • next.config.js:3 类 rewrite + redirects + headers + basePath(可注释切换)
  • middleware.ts:i18n 检测 + CSP nonce
  • app/[lang]/page.tsx / app/[lang]/posts/[slug]/page.tsx:App Router 风格 i18n
  • app/api/proxy/route.ts:演示用 middleware 做 API proxy

启动:

pnpm install && pnpm build && pnpm start
# 访问 http://localhost:3031
# 看 i18n detection;rewrite;redirect

十、本讲小结

  1. Routing pipeline 8 阶段:headers → redirects → middleware → beforeFiles → check_fs → afterFiles → check:true → fallback。
  2. 3 类 rewrite:beforeFiles 覆盖 page,afterFiles 兜底 page,fallback 兜底一切。
  3. App Router i18n 必须用 [lang] + middleware;next.config.jsi18n 只对 Pages Router 生效。
  4. basePath / assetPrefix 在 CDN 部署里是关键,注意 dev/prod 行为差异。
  5. Proxy 链每一层都要协作:CDN 长 cache 静态、短 cache HTML;Nginx 传 host header;Next.js 处理 forwarded headers。

下讲预告

第 32 讲《Next.js 的安全模型:CSRF、Server Action 防御、headers 过滤》。会深入 server action 的 origin check、Next.js 内部 header 过滤(INTERNAL_HEADERS)、next.config.jsexperimental.serverActions.allowedOrigins、以及为什么 RSC payload 路径需要特殊处理。