- 发布日期
第 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"。
学习目标
- 看懂
resolveRoutes的 routing pipeline 8 个阶段(headers → redirects → middleware → beforeFiles → check_fs → afterFiles → check:true → fallback)。 - 理解 3 类 rewrite 的差异:beforeFiles / afterFiles / fallback。
- 用 i18n + domains 配置出"中文站走 .cn,英文站走 .com"的多域名站点。
- 理解 basePath / assetPrefix 在 CDN / 反向代理后的作用,避免 chunk 404。
- 排查 5 类常见 routing 问题:rewrite 没生效、locale prefix 丢失、headers 加错位置、middleware 与 rewrite 交互混乱、SSR resource path 错。
一、3 个能力的边界
| 配置 | 行为 | 用户感知 URL | 客户端跳转 |
|---|---|---|---|
redirects | server 返回 30x 头 | 改变 | 是 |
rewrites | server 把 path 改写后再 match | 不变 | 否 |
headers | server 在响应里加自定义 header | 不变 | 否 |
basePath | 整个站挂到 /basePath/* 下 | 改变 | / |
i18n | 加 locale prefix;做 locale 检测 | 改变 | / |
rewrite 是 URL 不变 + 实际渲染另一个 page。最常见用法:
/blog/iphone← rewrite →/products/iphone(隐藏内部路径)/api/*← rewrite → 外部服务(反向代理)
redirect 是 URL 改变 + 浏览器跳转。常见用法:
- 老 URL 迁移:
/old-blog/:slug→/blog/:slug301
二、Routing pipeline 总览
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 重定向到 /maintenance | beforeFiles |
/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 路由实现
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 路由 404 | API 路由也要带 basePath:fetch('/blog/api/...') |
| middleware 处理时漏 basePath | req.nextUrl.pathname 已 strip basePath,但 req.url 没有 |
| dev 模式正常,prod 404 | assetPrefix 在 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 / 反向代理的协作
完整请求链:
浏览器
↓
DNS → CDN (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 Pattern | TTL | Headers Forwarded |
|---|---|---|
/_next/static/* | 1 year | none |
/_next/image* | 1 day | Accept |
/* | 60s | Accept-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。
排查:
- 检查 rewrite 是 beforeFiles 还是 afterFiles?
- 是否同时有
/blog/[slug]/page.tsx?afterFiles 会被它截断 - dev 模式 routes-manifest 是否最新?重启 dev server
- 检查 build log 输出的路由表
案例 2:i18n locale 总是 default
症状:访问 /en-US/products 但页面文案是中文。
排查:
- App Router 不支持
next.config.js的i18n,必须用[lang]segment + middleware - middleware
matcher是否排除了/_next/static,否则 static asset 也走 i18n 检测 params.lang是否被 page 实际读取并传递
案例 3:basePath 下 chunk 404
症状:配 basePath: '/blog',访问 /blog/,控制台 chunks/page-xxx.js 404。
排查:
- CDN 是否把
/blog/_next/static/*配置了正确的源? assetPrefix是否带了 protocol?应该是https://cdn.example.com/blog- dev 模式正常 prod 异常 →
assetPrefixdev 被忽略,prod 才生效 next start直接跑能否正确加载?如果可以,问题在反向代理
案例 4:middleware 改了 path 但 page 404
症状:middleware 里 NextResponse.rewrite(new URL('/foo', req.url)),但 page 拿不到。
排查:
- middleware 的
matcher是否覆盖了原 path? - rewrite 目标 page 是否存在?
- middleware 跑在 edge runtime,使用 Node-only API 会报错
案例 5:reverse proxy 后 SSR URL 错
症状:用户访问 https://example.com/foo,但服务端 headers().get('host') 是内部 IP。
排查:
- Nginx 加
proxy_set_header Host $host proxy_set_header X-Forwarded-Host $host- Next.js 默认信任
X-Forwarded-*(在experimental.trustHostHeader) - 在生产环境用
request.headers.get('host')而非req.url
九、配套 fixture
fixtures/lecture-31/ 提供:
next.config.js:3 类 rewrite + redirects + headers + basePath(可注释切换)middleware.ts:i18n 检测 + CSP nonceapp/[lang]/page.tsx/app/[lang]/posts/[slug]/page.tsx:App Router 风格 i18napp/api/proxy/route.ts:演示用 middleware 做 API proxy
启动:
pnpm install && pnpm build && pnpm start
# 访问 http://localhost:3031
# 看 i18n detection;rewrite;redirect
十、本讲小结
- Routing pipeline 8 阶段:headers → redirects → middleware → beforeFiles → check_fs → afterFiles → check:true → fallback。
- 3 类 rewrite:beforeFiles 覆盖 page,afterFiles 兜底 page,fallback 兜底一切。
- App Router i18n 必须用
[lang]+ middleware;next.config.js的i18n只对 Pages Router 生效。 - basePath / assetPrefix 在 CDN 部署里是关键,注意 dev/prod 行为差异。
- 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.js 的 experimental.serverActions.allowedOrigins、以及为什么 RSC payload 路径需要特殊处理。