这篇写给正在用 Next.js App Router 做多语言站点、需要每个页面正确输出 hreflang 和 sitemap 的开发者。要解决的问题只有一个:hreflang、sitemap、语言切换器三处的语言映射如何保证永远一致。
hreflang 是告诉搜索引擎「这个页面还有哪些语言版本、分别在哪个 URL」的一组<link rel="alternate">标签,Google 要求每组里的每个 URL 都互相列出对方(对称),并且建议提供x-default。多语言站点常见的病是三处映射各写一份:页面元数据里一份、sitemap 里一份、语言切换器里一份,加一个翻译页要改三处,漏一处搜索引擎就收到自相矛盾的信号。下面这套做法用一份注册表当唯一事实来源,三处输出全部派生。线上实例是一个 9 种语言、sitemap 里 132 个 URL、906 条 hreflang 交叉引用的站点。
先说利益关系:示例站点是我参与的产品 EditTextImage(edittextimage.com),它的功能是改掉成品图里已经印上去的文字并保留原字体和背景;文中的 URL 都是它的真实路由。
环境版本
- Next.js 16.2.4,App Router,Turbopack 构建
- TypeScript 5,React 19
- 部署在 Vercel,页面全部静态预渲染
- 验证日期:2026-09-23
Next.js 16 的 Metadata API 和 15 基本一致,本文用到的alternates.canonical、alternates.languages和app/sitemap.ts在 14 以上都可用;但 16 有若干与旧版不兼容的改动,动手前建议先读一遍node_modules/next/dist/docs/里对应的文档。
第一步:定义注册表
注册表就是一个数组,每个条目描述「一个页面在哪些语言下存在、URL 各是什么」。硬约束只有一条:每个条目必须有en(默认语言),这样任何派生出来的链接都不会指向不存在的页。
// lib/i18n/config.tsexportconstLOCALES=["en","es","pt","ja","ko","zh-tw","ru","de","fr"]asconst;exporttypeLocale=(typeofLOCALES)[number];exportconstDEFAULT_LOCALE:Locale="en";exportconstBASE_URL="https://edittextimage.com";// hreflang 用的语言代码。zh-tw 必须按 BCP 47 写成大小写混合的 zh-TW,// 其余用纯语言码("en" 而不是 "en-US"),面向全球而不是单个国家。exportfunctionhreflangCode(locale:Locale):string{if(locale==="zh-tw")return"zh-TW";returnlocale;}// lib/i18n/routes.tsexportinterfacePageEntry{/** 页面用它查自己:buildAlternates(id, locale) */id:string;/** 默认语言 URL 的 sitemap 优先级;非默认语言自动 -0.05 */priority:number;changeFrequency?:"weekly"|"monthly"|"yearly";/** 每种语言的路径,必须包含 en */urls:Partial<Record<Locale,string>>&{en:string};}exportconstPAGES:PageEntry[]=[{id:"screenshot",priority:0.9,urls:{en:"/edit-text-in-screenshot",pt:"/pt/editar-texto-em-print",ru:"/ru/redaktirovat-tekst-na-skrinshote",ja:"/ja/sukusho-moji-henshu",ko:"/ko/seukeurinsyat-geulja-pyeonjip","zh-tw":"/zh-tw/bianji-jietu-wenzi",de:"/de/text-im-screenshot-bearbeiten",fr:"/fr/modifier-texte-capture-ecran",es:"/es/editar-texto-en-captura-de-pantalla",},},// 只有英文的页面也要登记,否则 sitemap 和切换器都看不见它{id:"meme",priority:0.85,urls:{en:"/edit-text-in-meme"}},// …];constBY_ID=newMap(PAGES.map((p)=>[p.id,p]));exportfunctionpageById(id:string):PageEntry|undefined{returnBY_ID.get(id);}/** 某页在 locale 下的 URL,没有翻译时回退到英文,永远不返回死链 */exportfunctionurlForLocale(id:string,locale:Locale):string|undefined{constentry=BY_ID.get(id);returnentry?(entry.urls[locale]??entry.urls.en):undefined;}/** 反查:一个路径属于哪个条目、当前是哪种语言 */exportfunctionpageByPath(path:string):{entry:PageEntry;locale:Locale}|undefined{for(constentryofPAGES){for(const[locale,url]ofObject.entries(entry.urls)){if(url===path)return{entry,locale:localeasLocale};}}returnundefined;}两个设计点值得说明。每种语言的 slug 是各自语言的词,不是英文 slug 加前缀(/pt/editar-texto-em-print而不是/pt/edit-text-in-screenshot),这是本地化 SEO 的基本要求;注册表的价值之一就是把这种不规则映射集中管起来。翻译页的存在与否由注册表决定:加一个翻译 = 在条目里加一行 URL + 建一个页面文件,hreflang、sitemap、切换器自动跟上。
第二步:从注册表派生 hreflang
每个页面的metadata.alternates不再手写,调一个函数:
// lib/i18n/metadata.tsimporttype{Metadata}from"next";import{BASE_URL,DEFAULT_LOCALE,hreflangCode,typeLocale}from"./config";import{pageById}from"./routes";exportfunctionbuildAlternates(id:string,locale:Locale):Metadata["alternates"]{constentry=pageById(id);if(!entry){thrownewError(`buildAlternates: unknown page id "${id}"`);}constself=entry.urls[locale];if(!self){thrownewError(`buildAlternates: page "${id}" has no "${locale}" url`);}constlanguages:Record<string,string>={};for(const[loc,path]ofObject.entries(entry.urls)){languages[hreflangCode(locasLocale)]=BASE_URL+path;}// x-default 指向默认语言(英文)的 URLlanguages["x-default"]=BASE_URL+(entry.urls[DEFAULT_LOCALE]??entry.urls.en);return{canonical:BASE_URL+self,languages,};}页面里只剩一行:
// app/pt/editar-texto-em-print/page.tsxexportconstmetadata={title:"…",description:"…",alternates:buildAlternates("screenshot","pt"),};注意两处throw:id 写错或者给不存在的语言调用,构建期就失败,而不是上线后静默输出一组错误的 hreflang。这是把映射集中化换来的最大好处——错误会在最早的时刻暴露。
对称性是自动满足的:同一个条目的 9 个页面各自调buildAlternates,拿到的是同一张表,所以每个页面都列出了包括自己在内的全部语言版本,正好是 Google 的要求。
第三步:从注册表派生 sitemap
// app/sitemap.tsimporttype{MetadataRoute}from"next";import{BASE_URL,DEFAULT_LOCALE,hreflangCode,typeLocale}from"@/lib/i18n/config";import{PAGES}from"@/lib/i18n/routes";exportdefaultfunctionsitemap():MetadataRoute.Sitemap{constnow=newDate().toISOString();constentries:MetadataRoute.Sitemap=[];for(constpageofPAGES){constlocales=Object.keys(page.urls)asLocale[];for(constlocaleoflocales){// 每个 URL 都列出全部语言版本(含自己)+ x-defaultconstlanguages:Record<string,string>={};for(constotheroflocales){languages[hreflangCode(other)]=BASE_URL+page.urls[other]!;}languages["x-default"]=BASE_URL+page.urls[DEFAULT_LOCALE]!;entries.push({url:BASE_URL+page.urls[locale]!,lastModified:now,changeFrequency:page.changeFrequency??"weekly",priority:locale===DEFAULT_LOCALE?page.priority:Math.round((page.priority-0.05)*100)/100,...(locales.length>1?{alternates:{languages}}:{}),});}}returnentries;}alternates.languages会被 Next.js 渲染成 sitemap 里的<xhtml:link rel="alternate" hreflang="…">,这是 Google 支持的三种 hreflang 声明方式之一(另两种是 HTML<link>和 HTTP 头)。同一份注册表同时喂 HTML 和 sitemap,两处永远一致。
第四步:语言切换器也从注册表来
切换器最容易出的 bug 是「链到一个不存在的翻译页」。用pageByPath反查当前页属于哪个条目,只列出这个条目真有的语言:
// components/language-switcher.tsx(节选) export function LanguageSwitcher() { const pathname = usePathname(); const match = pageByPath(pathname); if (!match) return null; const { entry, locale: current } = match; const locales = Object.keys(entry.urls) as Locale[]; if (locales.length <= 1) return null; // 只有一种语言就不显示 return ( <div> {locales.map((loc) => loc === current ? ( <span key={loc} aria-current="true">{LANG_NAME[loc]}</span> ) : ( <Link key={loc} href={entry.urls[loc]!} hrefLang={hreflangCode(loc)}> {LANG_NAME[loc]} </Link> ) )} </div> ); }hrefLang属性顺手也加上,和 head 里的声明一致。
输出结果
线上页面/edit-text-in-screenshot的<head>(截取,2026-09-23):
<linkrel="canonical"href="https://edittextimage.com/edit-text-in-screenshot"/><linkrel="alternate"hrefLang="en"href="https://edittextimage.com/edit-text-in-screenshot"/><linkrel="alternate"hrefLang="pt"href="https://edittextimage.com/pt/editar-texto-em-print"/><linkrel="alternate"hrefLang="ru"href="https://edittextimage.com/ru/redaktirovat-tekst-na-skrinshote"/><linkrel="alternate"hrefLang="ja"href="https://edittextimage.com/ja/sukusho-moji-henshu"/><linkrel="alternate"hrefLang="ko"href="https://edittextimage.com/ko/seukeurinsyat-geulja-pyeonjip"/><linkrel="alternate"hrefLang="zh-TW"href="https://edittextimage.com/zh-tw/bianji-jietu-wenzi"/><linkrel="alternate"hrefLang="de"href="https://edittextimage.com/de/text-im-screenshot-bearbeiten"/><linkrel="alternate"hrefLang="fr"href="https://edittextimage.com/fr/modifier-texte-capture-ecran"/><linkrel="alternate"hrefLang="es"href="https://edittextimage.com/es/editar-texto-en-captura-de-pantalla"/><linkrel="alternate"hrefLang="x-default"href="https://edittextimage.com/edit-text-in-screenshot"/>同一页在sitemap.xml里的条目:
<url><loc>https://edittextimage.com/edit-text-in-screenshot</loc><xhtml:linkrel="alternate"hreflang="en"href="https://edittextimage.com/edit-text-in-screenshot"/><xhtml:linkrel="alternate"hreflang="pt"href="https://edittextimage.com/pt/editar-texto-em-print"/><xhtml:linkrel="alternate"hreflang="ja"href="https://edittextimage.com/ja/sukusho-moji-henshu"/><xhtml:linkrel="alternate"hreflang="zh-TW"href="https://edittextimage.com/zh-tw/bianji-jietu-wenzi"/><!-- …其余语言省略… --><xhtml:linkrel="alternate"hreflang="x-default"href="https://edittextimage.com/edit-text-in-screenshot"/><lastmod>2026-09-22T10:47:53.735Z</lastmod><changefreq>weekly</changefreq><priority>0.9</priority></url>整站规模:sitemap 132 个 URL,906 条xhtml:link交叉引用,全部由注册表生成,没有一处手写。
验证方法
三条命令就够,不需要第三方工具:
# 1. head 里的 hreflang 是否成组且含 x-defaultcurl-shttps://你的域名/某个页面|grep-oE'<link rel="alternate"[^>]*>'# 2. sitemap 里 xhtml:link 的数量(每个多语言 URL 应有 n+1 条,n 为语言数)curl-shttps://你的域名/sitemap.xml|grep-o'<xhtml:link'|wc-l# 3. 对称性抽查:从 A 语言页取出 B 的 URL,再从 B 页确认它列出了 A上线后再到 Google Search Console 的「国际定位」报告看有没有「无返回标记」错误——那就是不对称的信号。
常见报错与排查
构建报buildAlternates: unknown page id "xxx"。页面里传的 id 和注册表的id不一致,多半是拼写。这是设计上的故意失败,改 id 即可。
构建报page "xxx" has no "ja" url。你建了app/ja/…/page.tsx但注册表条目里没加ja这一行。先加注册表,再建页面,顺序反了就会撞上。
Search Console 报「hreflang 无返回标记」。某个语言页的 head 没列出其他语言。在这套方案里几乎只有一种可能:那个页面没用buildAlternates,而是自己手写了alternates。全局搜一下alternates: {就能找到。
zh-TW 被 Search Console 判为无效代码。输出成了小写zh-tw。确认所有输出都经过hreflangCode(),不要有地方直接把 locale 字符串拼进去。
切换器链到 404。切换器没走pageByPath,而是按固定语言列表拼 URL。切换器只能列出注册表里该条目真有的语言。
sitemap 里 lastmod 全是构建时间。上面的代码用了new Date(),这是已知的简化;如果需要真实的更新时间,在PageEntry上加一个updated字段并在 sitemap 里优先使用它。
什么时候不适合这套做法
页面是从数据库或 CMS 动态生成的(例如几千篇文章各有多语言版本),注册表会变成手工维护的负担,这时映射应该存在数据层,由数据驱动generateStaticParams和 sitemap。这套做法适合的是「几十到一两百个手写落地页」的规模——每个页面本来就是一个文件,注册表只是把它们的语言关系集中起来。
另外它假设 canonical 就是页面自己。如果你的站点有「多个 URL 指向同一内容、需要 canonical 指向别处」的情况,buildAlternates里canonical: BASE_URL + self这一行要改成可配置的。
FAQ
hreflang 用en还是en-US?
面向全球用户的工具类站点用纯语言码en,它匹配所有英语用户;en-US只匹配美国。只有当同一语言在不同国家有真正不同的内容(价格、法规)时才用「语言-地区」码。zh-TW是例外:繁体中文没有独立的语言码,必须带地区。
只有英文的页面要不要写 hreflang?
不需要输出alternates.languages(上面 sitemap 代码里locales.length > 1的判断就是干这个的),但页面仍然要登记在注册表里,否则 sitemap 和切换器都不知道它存在。canonical 照常输出。
x-default 应该指向谁?
指向没有匹配语言时的兜底页,通常是默认语言版本或语言选择页。这里指向英文版,因为它是内容最完整的版本。九个语言版本的 x-default 都指向同一个英文 URL。
加一种新语言要改几处?
两处:LOCALES数组加一项、localeFromPathname加一个前缀判断;然后逐页在注册表条目里加 URL 并建页面文件。hreflang、sitemap、切换器不用动。