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

发布: (2025年12月30日 GMT+8 09:17)
8 min read
原文: Dev.to

Source: Dev.to

Cover image for “我将我的 8 语言应用迁移到 Next.js 16。随后 Google Search Console 对我大喊大叫。”

dev.chinasurvival

我以为我已经完成了。

我刚刚完成了 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 的极其简单的解决方案。

架构

组件技术
CoreNext.js 16(服务器组件对于减小打包体积是个福音)
UIshadcn/ui(Tailwind CSS)
i18nnext‑intl 处理路由(/en/ja/ko
HostingVercel

陷阱:当“相对”变成“无关”

Google 的爬虫很聪明,但也异常死板。

我的站点结构如下:

chinasurvival.com          (根目录,根据地区重定向)
├─ chinasurvival.com/en/…
└─ chinasurvival.com/ja/…

我在元数据中使用了标准的 canonical 标签。或者说,我以为是这样。

当我检查生产构建的源代码时,发现 Next.js(在 Vercel 的构建过程中)并不总是生成我预期的 URL。在某些构建环境中,process.env.NEXT_PUBLIC_SITE_URLundefined,或回退到 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.jsonja.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

  1. 绝不要依赖运行时环境变量来获取规范 URL。 请硬编码生产域名(或在受控的构建阶段注入)。
  2. 在服务器端生成 SEO 元数据(App Router),确保在发送给爬虫的 HTML 中包含正确的 canonical、hreflangx‑default 标签。
  3. 在生产环境中验证渲染出的 <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 中,paramssearchParams异步的。
如果你直接访问 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,这是一款帮助旅行者使用支付宝、本地交通和城市指南的工具。

👉 在此查看实时应用,体验 i18n 路由的实际效果。

Back to Blog

相关文章

阅读更多 »