18ways 博客

如何为 Next.js 应用添加多语言

如何在不影响 SEO 或从头重建的情况下,为现有的 Next.js 应用添加多语言 i18n 和本地化。

I18n(国际化)、l10n(本地化)、多语言支持……不管你怎么说,最终你都需要更新你的 Next.js 应用,让它支持多种语言。

难点不在于翻译一句话,而在于把多种语言添加到一个真实的 Next.js 应用中,同时又不破坏 SEO、不把代码库弄得一团乱,也不给自己埋下一个维护难题。

如果你想直接看代码,请查看 18ways-next GitHub 示例 

先把基础设施搭好

在做其他任何事情之前,我们先安装所需的包。我们将搭建 18ways 的库,不过如果你的项目比较简单,而且只有静态内容,像 i18next 这样的工具也同样很好用。

bash
npm install @18ways/next @18ways/react

创建你的配置文件:

js
// 18ways.config.js
module.exports = {
  apiKey: 'pk_dummy_demo_token',
  baseLocale: 'en-GB',
  router: 'app', // 'app', or 'path' depending on which Next.js router you are using
};

把你的 Next.js 配置包起来:

js
// next.config.js
const { withWays } = require('@18ways/next/config');
 
const nextConfig = {
  /*
   * your normal Next.js config here
   */ 
};
 
module.exports = withWays(nextConfig);

添加一个根代理,这样 / 就可以重定向到正确的区域设置:

js
// proxy.js
export { default, config } from '@18ways/next/proxy';

然后添加一个本地化布局:

jsx
// app/layout.jsx
import './styles.css';
import { WaysRoot } from '@18ways/next/server';
 
export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <body className="next-demo-body">
        <WaysRoot>{children}</WaysRoot>
      </body>
    </html>
  );
}

好了,我们完成了!你现在有:

翻译你的第一个页面

现在你可以翻译第一屏了。像 i18next 这样的工具会要求你把所有文本拆分成翻译键,然后在代码中引用它们。具体怎么做,取决于你选择的库。

如果你使用 18ways,只需把想翻译的文本包裹进 <T> 组件中:

jsx
// src/components/MyExampleComponent.jsx
import { useT, T } from '@18ways/react';
 
export default function MyExampleComponent() {
  const t = useT();
 
  return (
    <div>
      <T>Hello world!</T>
      <img
        src="https://example.com/image.png"
        alt={t('Example image')}
      />
    </div>
  );
}

你大概率会希望允许用户切换语言选择:

jsx
// src/components/Footer.jsx
import { T, LanguageSwitcher } from '@18ways/react';
 
export default function Footer() {
  return (
    <footer>
      <T>My footer content</T>
      <LanguageSwitcher />
    </footer>
  );
}

注意这些坑

一旦基础设施搭好,这些项目通常有几种常见的出错方式。

服务端渲染

服务器端渲染(SSR)能让你的 Next.js 应用在服务器上预渲染 HTML。这对于 SEO 以及避免用户看到内容闪烁或语言错误都至关重要。

如果你使用像 i18next 这样的库,就必须非常小心,确保你的翻译在服务端渲染期间已经被加载并填充进去。你可以通过查看页面的 view-source: 来测试这一点,例如 view-source:http://localhost:3000/。你也应该在生产环境中检查这一点,确保它在生产构建中也能正常工作。

如果你使用 18ways,就不用担心这些。它会全部替你处理好。

翻译键

如果你使用 18ways,根本不用担心这些。18ways 不需要翻译键,你可以像平常一样把文本原样放在那儿。

许多 i18n 系统要求你把代码拆成翻译键。为这些键命名时一定要小心。

糟糕的键都很模糊:

js
// bad-keys.en-GB.js
module.exports = {
  title: 'Continue',
  button: 'Pay now',
  label: 'Home',
};

这些键对译者和开发者来说,几乎无法说明文本会出现在哪里。

更好的键会包含上下文:

js
// better-keys.en-GB.js
module.exports = {
  'checkout.payment.primaryButton': 'Pay now',
  'checkout.payment.stepTitle': 'Complete your payment',
  'account.sidebar.homeLink': 'Home',
};

也要避免动态生成键:

js
// bad-dynamic-keys.js
const key = `checkout.${status}.${buttonType}`;
const translatedText = t(key);

这会破坏那些试图让翻译键不那么难受的 IDE 工具。它还会让查找并清理旧翻译键变得极其困难。

js
// better-dynamic-keys.js
const keyMap = {
  success: {
    primary: t('checkout.success.primary'),
    default: t('checkout.success.default'),
  },
  error: {
    primary: t('checkout.error.primary'),
    default: t('checkout.error.default'),
  },
};
 
const translatedText = keyMap[status][buttonType];

这样做没那么 DRY,但却是阻止翻译键失控的最佳方式。

更好的做法是使用像 18ways 这样的工具,这样你甚至完全不需要翻译键:

jsx
// app/[lang]/checkout/page.jsx
<T>Pay now</T>

区域检测

区域检测是指在用户明确切换之前,先决定用户应该看到哪种语言。

这通常涉及以下几种组合:

在普通的 Next.js 中间件里,这通常看起来像这样:

js
// middleware.js
 
/**
 * You don't need any of this if you're using 18ways 
 */
 
import { NextResponse } from 'next/server';
 
const acceptedLocales = ['en-GB', 'fr-FR'];
 
export function middleware(request) {
  const savedLocale =
    request.cookies.get('preferred-locale')?.value;
  const browserLocale =
    request.headers
      .get('accept-language')
      ?.split(',')[0] || 'en-GB';
 
  const locale = acceptedLocales.includes(savedLocale)
    ? savedLocale
    : acceptedLocales.includes(browserLocale)
      ? browserLocale
      : 'en-GB';
 
  if (request.nextUrl.pathname === '/') {
    return NextResponse.redirect(
      new URL(`/${locale}`, request.url)
    );
  }
 
  return NextResponse.next();
}

有些库提供辅助功能,可以在不同程度上让这件事更容易。使用 18ways 时,初始检测和重定向层都会为你处理好。

日期和区域设置相关的基础类型

不同地区对日期、数字和金额的格式化方式各不相同。

例如:

在原生 JavaScript 中,你会用 Intl 自己处理这一切:

js
// formatting-dates-and-currency.js
const myLocale = getCurrentLocale(); // depends on your lib
 
const someTimestamp = new Date('2026-04-13T09:00:00Z');
const dateLabel = new Intl.DateTimeFormat('fr-FR', {
  dateStyle: 'long',
}).format(someTimestamp);
 
const someMoney = {
  amount: 1999,
  currency: 'EUR',
};
const moneyLabel = new Intl.NumberFormat('de-DE', {
  style: 'currency',
  currency: someMoney.currency,
}).format(someMoney.amount);
 
const translatedText = t(
  'my.translation.key',
  { dateLabel, moneyLabel }
);

如果你使用 18ways,这些都会为你处理好:

jsx
const someTimestamp = new Date('2026-04-13T09:00:00Z');
const someMoney = {
  amount: 1999,
  currency: 'EUR',
};
 
<T>My text with {{ someTimestamp }} and {{ someMoney }}</T>

把字符串连起来

不要通过拼接碎片来构建已翻译的 UI:

jsx
// bad-string-joining.jsx
const clickHereText = t('click.here');
const toGetStartedText = t('to.get.started')
<p><a href="#">{clickHereText}</a> {toGetStartedText}.</p>

这在多种语言中都会失效。

你可能会用英语说“👉👉点击这里👈👈开始”。但在法语里,更自然的说法是“Pour commencer, 👉👉cliquez ici👈👈”。有些语言,比如日语,甚至需要把词放在前后两边,例如“始めるには👉👉こちら👈👈をクリックしてください”。

句子结构可能会变化,所以把句子拆成碎片会让翻译变得更困难。

完整句子更容易翻译,因为译者可以自然地调整词序,并将含义作为一个完整单元来理解。

如果你正在翻译这样的 JSX:

jsx
// rich-text-message.jsx
<p>
  Something <strong>that we want to be bold</strong>
</p>

你需要查阅所用 i18n 库关于如何管理这一点的说明,因为不同库的处理方式差异很大。

如果你使用的是 18ways,就可以像平常一样直接翻译整个 JSX 块。

jsx
// rich-text-message.jsx
<p>
  <T>Something <strong>that we want to be bold</strong></T>
</p>

变量

变量让你能保持句子完整,同时仍然插入运行时值。

大多数 i18n 库都以某种形式支持这一点。使用 18ways:

jsx
// app/[lang]/page.jsx
<T>Hello {{ name: 'Ada' }}</T>

这种模式很适合:

句子依然通顺,值也依然明确。

如果你还需要把变量本身也翻译掉,确保把它包进 t(...)

jsx
const animal = t('dog');
<T>Favourite animal: {{ animal }}</T>

复数形式

复数规则因语言而异。英语只有一种复数形式(1 year2 years 等)。波兰语有多种复数形式(1 rok2 lata5 lat)。日语则根本没有复数形式(1 年2 年3 年)。

大多数 i18n 库都允许你指定类似 ICU 的语法来处理复数:

jsx
// app/[lang]/page.jsx
<T>
  {{
    unreadCount,
    format:
      'plural, =0{No unread messages} =1{One unread message} other{{unreadCount} unread messages}',
  }}
</T>

上面的做法在 18ways 中都能奏效,但在大多数情况下,你也可以直接这样做:

jsx
// app/[lang]/page.jsx
<T>{{ unreadCount }} unread messages</T>

18ways 会帮你处理复数形式!

立即开始

为 Next.js 应用添加多语言支持,不必变成一场重写工程。

如果你没有使用像 18ways 这样的方案——请确保路由和 SSR 正确,然后再把文案迁移到翻译键中,同时务必避开常见的 i18n 陷阱。

如果你使用的是 18ways,这些都会为你处理好,你只需要开始把文本包裹在 <T> 块中!

正在切换语言
如何为 Next.js 应用添加多种语言 | 18ways 博客