我将我的 8 语言应用迁移到 Next.js 16。随后 Google Search Console 对我大声喊叫。
Source: Dev.to

我以为我已经完成了。
我刚刚完成了 China Survival Kit——我为游客构建的旅行工具——的大规模迁移,将其搬到了 Next.js 16(App Router) 的前沿。性能指标显示为绿色。next‑intl 对 8 种语言(英语、日语、韩语等)的集成在浏览器中运行完美。
我将代码推送到生产环境,感觉良好,然后去睡觉了。
两天后,我打开了 Google Search Console(GSC)。结果是一片血腥。
“页面未被索引:重复且未选择规范页面。”
Google 拒绝索引我的城市指南(/en/guides/cities、/ja/guides/cities)。它认为我的英文页面是根域的重复,于是基本上把我花了数周时间构建的专门内容去索引了。
如果你在 Vercel 上使用 Next.js 构建多语言站点,可能会掉进这个陷阱。下面是发生了什么,以及拯救我 SEO 的极其简单的解决方案。
架构
| 组件 | 技术 |
|---|---|
| Core | Next.js 16(服务器组件对于减小打包体积是个福音) |
| UI | shadcn/ui(Tailwind CSS) |
| i18n | next‑intl 处理路由(/en、/ja、/ko) |
| Hosting | Vercel |
陷阱:当“相对”变成“无关”
Google 的爬虫很聪明,但也异常死板。
我的站点结构如下:
chinasurvival.com (根目录,根据地区重定向)
├─ chinasurvival.com/en/…
└─ chinasurvival.com/ja/…
我在元数据中使用了标准的 canonical 标签。或者说,我以为是这样。
当我检查生产构建的源代码时,发现 Next.js(在 Vercel 的构建过程中)并不总是生成我预期的 URL。在某些构建环境中,process.env.NEXT_PUBLIC_SITE_URL 为 undefined,或回退到 localhost。
于是我的 canonical 标签渲染成了这样:
Google 会忽略 localhost URL。 由于它认为 canonical 标签无效,便回退到自己的逻辑:“嘿,这个 /en 页面和根页面完全相同。我就把根页面编入索引,把这个页面扔进垃圾桶吧。”
修复方案:硬编码你的生产环境
我们开发者热爱环境变量,热衷于让一切变得动态。但在静态站点生成器的 SEO 场景下,一致性才是王道。
我不再尝试用动态的基础 URL 来生成 canonical 标签,而是强制使用生产环境的 URL 作为唯一真实的地址。
1. “强硬”Canonical
在 lib/seo.ts 中,我更新了元数据生成器,确保它永远不依赖不可靠的构建时变量来获取域名。
// lib/seo.ts
import { Metadata } from 'next';
export function constructMetadata({
title,
description,
path = '',
locale = 'en',
}: MetadataProps): Metadata {
// 🛑 STOP doing this:
// const siteUrl = process.env.NEXT_PUBLIC_SITE_URL;
// const siteUrl = process.env.VERCEL_URL; // Don't do this either!
// ✅ DO this. Force the bot to see the real domain.
const siteUrl = 'https://www.chinasurvival.com';
// Ensure path starts with a slash
const cleanPath = path.startsWith('/') ? path : `/${path}`;
// Construct the absolute URL
const canonicalUrl = `${siteUrl}/${locale}${cleanPath}`;
return {
title,
description,
alternates: {
canonical: canonicalUrl, // This must be bulletproof
languages: {
en: `${siteUrl}/en${cleanPath}`,
ja: `${siteUrl}/ja${cleanPath}`,
ko: `${siteUrl}/ko${cleanPath}`,
de: `${siteUrl}/de${cleanPath}`,
// ... other languages
// Crucial: Tells Google "If no language matches, send them here"
'x-default': `${siteUrl}/en${cleanPath}`,
},
},
};
}
为什么不使用 VERCEL_URL?
VERCEL_URL 是动态的。在预览部署时它会生成类似 git-branch-project.vercel.app 的地址。我不希望 canonical 标签指向临时的预览域名,而是希望它始终指向唯一的正式生产域名,无论构建发生在何处。
2. 将 SEO 移到服务器组件
使用 Next.js 16 后,我把所有 SEO 所需的数据获取都搬到了 page.tsx 本身。再也不需要 useEffect 之类的乱七八糟。
因为我的 SEO 文本存放在翻译文件(en.json、ja.json)中,我在服务器端使用 next‑intl 根据 slug 动态读取对应内容。
// app/[locale]/guides/cities/[slug]/page.tsx
import { constructMetadata } from '@/lib/seo';
import { getTranslations } from 'next-intl/server';
// Map slugs to translation keys
const slugToKeyMap: Record<string, string> = {
beijing: 'Guides_Cities_Beijing',
shanghai: 'Guides_Cities_Shanghai',
// ... other cities
};
export async function generateMetadata({ params }: Props) {
// ⚠️ Next.js 16 BREAKING CHANGE: `params` is now a Promise!
const { locale, slug } = await params;
const t = await getTranslations('guides.cities');
const titleKey = slugToKeyMap[slug] ?? 'Guides_Cities_Default';
const title = t(`${titleKey}.title`);
const description = t(`${titleKey}.description`);
return constructMetadata({
title,
description,
path: `/guides/cities/${slug}`,
locale,
});
}
// ...rest of the page component
现在每个页面都会渲染一个 坚如磐石的 canonical URL,始终指向 https://www.chinasurvival.com,无论构建在何处进行。
TL;DR
- 绝不要依赖运行时环境变量来获取规范 URL。 请硬编码生产域名(或在受控的构建阶段注入)。
- 在服务器端生成 SEO 元数据(App Router),确保在发送给爬虫的 HTML 中包含正确的 canonical、
hreflang和x‑default标签。 - 在生产环境中验证渲染出的
<link rel="canonical">——它应该是 Google 能抓取的绝对 URL。
部署这些更改后,Google 在一天内重新索引了 /en/guides/cities 和 /ja/guides/cities 页面,站点的 SEO 健康度恢复为绿色。 🎉
你没有等待它,构建将会失败
const { locale, slug } = await params;
const jsonKey = slugToKeyMap[slug];
// Dynamic Server‑Side Translation Fetching
const t = await getTranslations({ locale, namespace: jsonKey });
return constructMetadata({
title: t('meta_title'), // "Beijing Travel Guide 2025..."
description: t('meta_description'),
path: `/guides/cities/${slug}`, // Passing the specific path for the canonical
locale,
});
关于 Next.js 15/16 的说明
在最新版本的 Next.js 中,params 和 searchParams 是 异步的。
如果你直接访问 params.slug 而不先 await,会出现恼人的运行时错误。
3. 站点地图策略
我还更新了 sitemap.ts,使用 flatMap。这确保即使 Google 访问的是英文页面,站点地图也会明确地把日文和韩文版本直接呈现出来。
// app/sitemap.ts
const routes = [
'',
'/guides/cities',
'/guides/internet',
// ... other static routes
];
const locales = ['en', 'ko', 'ja', 'es', 'fr', 'de', 'ru', 'pt'];
export default function sitemap(): MetadataRoute.Sitemap {
// Force production URL here too
const baseUrl = 'https://www.chinasurvival.com';
return locales.flatMap((locale) => {
return routes.map((route) => ({
url: `${baseUrl}/${locale}${route}`,
lastModified: new Date(),
priority: route === '' ? 1.0 : 0.8,
}));
});
}
事后
我部署了修复后,回到 Google Search Console,点击 “检查 URL” 检查 /en/guides/cities 页面。
结果:
规范 URL 终于显示为 https://www.chinasurvival.com/en/guides/cities。
我点击 “请求索引”。 48 小时后,“重复”错误消失,我的日文页面开始在东京的用户搜索结果中出现。
教训
在 SEO 元数据方面,显式优于隐式。不要指望构建环境自行猜测你的 URL。
我构建了这套架构来支持 China Survival Kit,这是一款帮助旅行者使用支付宝、本地交通和城市指南的工具。
