← 文章
tutorial·2026-07-04·18 min read

Next.js 博客国际化:内容层与 UI 层的分离架构

两套独立的 i18n 体系如何协作,以及在 URL、阅读时间、Sitemap、SEO 上踩的坑

目录

给一个 Next.js 博客加多语言支持,最容易想到的就是装一个 next-intl 然后把所有字符串扔进 JSON。但真正动手之后会发现,博客的「多语言」其实分两层:一层是 UI 文案(导航、按钮文字),另一层是内容本身(MDX 文章)。这两层的更新频率、维护方式、技术实现完全不同。

这篇文章记录我在 Chaos 博客里采用的分离架构,以及在 URL 策略、阅读时间计算、Sitemap 生成、移动菜单状态管理等方面遇到的问题和解法。

架构总览:两层 i18n,各司其职

整个国际化分成两套独立体系:

UI 层由 next-intl 管理,翻译文件放在 src/i18n/messages/ 下面,按语言一个 JSON 文件:

src/i18n/messages/
├── zh.json # 中文 UI 文案
└── en.json # 英文 UI 文案

里面是导航、按钮、页面标题这类 UI 字符串:

{
"nav": { "writing": "文章", "photos": "摄影", "projects": "项目" },
"writing": {
"title": "文章",
"backToList": "← 文章",
"minRead": "{n} min read"
}
}

内容层则是文件系统驱动的 MDX 文章,按语言分目录存放在 content/ 下面:

content/
├── zh/posts/ # 中文文章
│ ├── app-router-page-transition.mdx
│ └── nextjs-i18n-blog-architecture.mdx
└── en/posts/ # 英文文章
├── app-router-page-transition.mdx
└── nextjs-i18n-blog-architecture.mdx

为什么要分开?因为 UI 文案和文章内容的特性完全不同:

  • UI 文案数量少、结构固定、中英文一一对应——适合 JSON key-value
  • MDX 文章篇幅长、更新不同步、可能只有中文版——适合文件系统按目录隔离

加载逻辑也不一样。UI 文案通过 src/i18n/request.ts 在请求级别动态 import:

export default getRequestConfig(async ({ requestLocale }) => {
let locale = await requestLocale
if (!locale || !routing.locales.includes(locale as 'zh' | 'en')) {
locale = routing.defaultLocale
}
return {
locale,
messages: (await import(`./messages/${locale}.json`)).default,
}
})

而文章内容通过 src/lib/posts.ts 里的 getPostsDir(locale) 从对应目录读取:

function getPostsDir(locale: string) {
return path.join(contentDir, locale, 'posts')
}

两条数据流,各走各的。

localePrefix: 'as-needed':默认语言不带前缀

URL 策略是 i18n 博客的第一个决策点。我用了 as-needed 模式:

export const routing = defineRouting({
locales: ['zh', 'en'],
defaultLocale: 'zh',
localePrefix: 'as-needed',
})

效果是:默认语言(中文)的 URL 没有前缀,非默认语言(英文)才加 /en/

语言URL
中文miaoosi.com/writing/some-post
英文miaoosi.com/en/writing/some-post

这比 always 策略(所有语言都加前缀)更简洁,也更符合中文读者为主的博客。

但这会给生成 canonical URL 和 alternates 带来一点麻烦——你得根据 locale 判断要不要加前缀。所以封了一个 getLocalizedUrl() 工具函数:

const SITE_URL = 'https://miaoosi.com'
export function getLocalizedUrl(locale: string, pathname = '') {
const path = pathname === '/' ? '' : pathname.startsWith('/') ? pathname : `/${pathname}`
if (locale === routing.defaultLocale) return `${SITE_URL}${path}`
return `${SITE_URL}/${locale}${path}`
}

逻辑很直白:默认 locale 直接拼路径,非默认的插入 /${locale}。整个项目里所有需要生成完整 URL 的地方(SEO metadata、sitemap、JSON-LD)统一调这个函数。

阅读时间:中文数字符,英文数单词

阅读时间估算看起来简单,但中英文的计算方式完全不同。英文按空格分词,中文没有空格分隔。

src/components/PostDetail.tsx 里的实现:

function readingTime(locale: string, content: string): number {
const wpm = locale === 'zh' ? 350 : 200
const words = locale === 'zh'
? content.replace(/\s+/g, '').length
: content.trim().split(/\s+/).length
return Math.max(1, Math.round(words / wpm))
}

中文路径的关键是 content.replace(/\s+/g, '').length——先去掉所有空白字符(换行、空格),然后直接数字符数量。中文阅读速度大约 350 字/分,英文大约 200 词/分。

有一个已知的不精确之处:传入的 rawContent 是去掉 frontmatter 后的 MDX 原始文本,包含 Markdown 语法标记(##**、链接语法等)。这些标记会略微膨胀字符数,导致估算偏高。但对于「粗略估算」来说足够了——精确到分钟级别,多算几十个字符影响不大。

使用时,在文章详情页把原始内容传进来:

const rawContent = readPostRawContent(locale, slug)
// ...
<PostDetail post={post} locale={locale} rawContent={rawContent} toc={tableOfContents}>
<MDXContent />
</PostDetail>

Sitemap 与 generateStaticParams:同步陷阱

Sitemap 和静态参数生成有一个容易忽略的问题:它们都以中文目录为「权威来源」,然后映射到所有 locale。

src/app/sitemap.ts 里,文章页的生成逻辑是:

const posts = await getAllPosts('zh')
const postPages = posts.flatMap(post =>
routing.locales.map(locale => ({
url: getLocalizedUrl(locale, `/writing/${post.slug}`),
lastModified: new Date(post.date),
alternates: {
languages: {
zh: getLocalizedUrl('zh', `/writing/${post.slug}`),
en: getLocalizedUrl('en', `/writing/${post.slug}`),
},
},
})),
)

generateStaticParams 同理:

export async function generateStaticParams() {
const posts = await getAllPosts('zh')
return posts.flatMap(post =>
(['zh', 'en'] as const).map(locale => ({ locale, slug: post.slug })),
)
}

问题在哪? 两处都是从 zh 目录拿文章列表,然后假设 en 目录也有同名文件。如果某篇文章只有中文版没有英文版:

  1. Sitemap 会生成一个不存在的英文 URL → 搜索引擎抓取到 404
  2. generateStaticParams 会生成 { locale: 'en', slug: 'xxx' } → build 阶段 loadPostMDX 找不到文件,构建失败

目前我的博客中英文文章是一一对应的,所以暂时没爆。但这是个隐患。稳妥的做法是加一层存在性检查——比如在 sitemap 生成时过滤掉不存在的 locale 版本,或者在 generateStaticParams 里只生成实际有文件的组合。

移动菜单:path 感知的状态管理

移动端汉堡菜单有一个经典问题:点击菜单项导航后,菜单需要自动关闭。常见做法是给每个链接加 onClick={closeMenu},或者监听路由变化事件。

src/components/Nav.tsx 里用了一个更巧妙的状态设计:

const [menuState, setMenuState] = useState<{ open: boolean; path: string | null }>({
open: false,
path: null,
})
const open = menuState.open && menuState.path === pathname

状态不是简单的 boolean,而是 { open, path } 的组合。菜单「打开」的判定条件是:open === true path 等于当前 pathname

用户在菜单里点击一个链接 → 导航发生 → pathname 变了 → menuState.path !== pathname → 菜单自动「关闭」。不需要额外的事件监听,也不需要在每个链接上手动调 closeMenu(虽然 Logo 链接还是加了 onClick={closeMenu} 做保险)。

function toggleMenu() {
if (open) {
closeMenu()
} else {
setMenuState({ open: true, path: pathname })
}
}

这个模式的好处是把路由变化和菜单状态解耦了——菜单不需要「知道」导航何时发生,它只关心当前路径是否还和打开时一致。

Middleware:为什么叫 proxy.ts

Next.js 的 middleware 通常放在 src/middleware.ts。但这个项目里,middleware 的逻辑在 src/proxy.ts

import createMiddleware from 'next-intl/middleware'
import { routing } from './i18n/routing'
export default createMiddleware(routing)
export const config = {
matcher: [
'/',
'/((?!api|_next|_vercel|.*\\..*).*)',
],
}

功能很简单:用 next-intl 的 createMiddleware 做 locale 检测和重定向。matcher 排除了 API 路由、Next.js 内部路径和静态文件。

文件名之所以是 proxy.ts 而不是默认的 middleware.ts,是因为 Next.js 允许通过 next.config.ts 或项目配置改变 middleware 的入口位置。这种命名在需要包一层中间件代理(比如同时处理鉴权和 i18n)时更有语义。不过要注意,如果没有相应的配置把 proxy.ts 指定为 middleware 入口,Next.js 是不会自动识别的。

SEO:canonical 与 alternates

每个页面的 generateMetadata 都设置了 canonical URL 和 language alternates,告诉搜索引擎不同语言版本的对应关系:

export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>
}): Promise<Metadata> {
const { locale } = await params
const t = await getTranslations({ locale, namespace: 'home' })
return {
title: 'Chaos — Frontend & Infrastructure',
description: t('desc'),
alternates: {
canonical: getLocalizedUrl(locale),
languages: {
zh: getLocalizedUrl('zh'),
en: getLocalizedUrl('en'),
},
},
}
}

文章详情页还额外加了 OpenGraph 和 JSON-LD 结构化数据:

const jsonLd = {
'@context': 'https://schema.org',
'@type': 'BlogPosting',
headline: post.title,
datePublished: post.date,
inLanguage: locale === 'zh' ? 'zh-CN' : 'en',
url: getLocalizedUrl(locale, `/writing/${slug}`),
author: { '@type': 'Person', name: 'Chaos', url: getLocalizedUrl(locale) },
}

所有 URL 生成都走 getLocalizedUrl(),保证 canonical 和 alternates 的格式一致——默认 locale 没有前缀,非默认的有 /en/

总结

决策点方案权衡
UI 文案 vs 文章内容JSON(next-intl) vs 文件系统(MDX 按目录)各自独立更新,但两层需手动保持同步
URL 前缀策略localePrefix: 'as-needed'默认语言 URL 干净,但需要 getLocalizedUrl() 统一处理
阅读时间计算中文数字符(350 字/分)、英文数词(200 词/分)Markdown 语法标记会略微膨胀估算值
Sitemap / 静态参数zh 目录枚举,映射到所有 locale如果英文版缺失会导致 404 或构建失败
移动菜单状态{ open, path } 组合,路径不匹配即关闭无需事件监听,导航自动关闭菜单
Middleware 文件名src/proxy.ts语义更清晰,但需要配置支持
SEO alternatesgenerateMetadata + getLocalizedUrl()所有页面统一模式,canonical 与 alternates 一致

博客国际化的复杂度不在于「翻译」本身,而在于 URL 策略、内容同步、SEO 信号这些基础设施层面的决策。把 UI 层和内容层拆开之后,每一层都能独立演进——UI 文案改了不用碰文章,文章加了新语言不用改代码。