18ways 博客
如何在不伤害 SEO、也不从头重建的情况下,为现有的 Next.js 应用添加多语言 i18n 和本地化。
I18n(国际化)、l10n(本地化)、多语言支持……不管你怎么叫它,最终你都需要更新你的 Next.js 应用,让它支持一种以上的语言。
难点不在于翻译一句话。而在于把多种语言加入一个真实的 Next.js 应用里,同时不破坏 SEO、不把代码库搞得一团乱,也不给自己埋下维护难题。
如果你想直接看代码,查看 18ways-next GitHub 示例 。
首先,我们需要安装所需的软件包。我们会配置 18ways 的库,但如果你的项目比较简单,只包含静态内容,那么 i18next 之类的工具也很好用。
npm install @18ways/next @18ways/react创建你的配置文件:
// 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 配置包起来:
// next.config.js
const { withWays } = require('@18ways/next/config');
const nextConfig = {
/*
* your normal Next.js config here
*/
};
module.exports = withWays(nextConfig);添加一个根代理,这样 / 就能重定向到正确的语言区域:
// proxy.js
export { default, config } from '@18ways/next/proxy';然后添加一个本地化布局:
// 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> 组件中:
// 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>
);
}你大概会希望允许用户切换自己的语言选择:
// 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 系统都要求你把代码拆成一个个翻译键。为这些键命名时一定要小心。
糟糕的键很含糊:
// bad-keys.en-GB.js
module.exports = {
title: 'Continue',
button: 'Pay now',
label: 'Home',
};这些键几乎无法让译者和开发者知道文本会出现在哪里。
更好的键会包含上下文:
// better-keys.en-GB.js
module.exports = {
'checkout.payment.primaryButton': 'Pay now',
'checkout.payment.stepTitle': 'Complete your payment',
'account.sidebar.homeLink': 'Home',
};也要避免动态生成键:
// bad-dynamic-keys.js
const key = `checkout.${status}.${buttonType}`;
const translatedText = t(key);这会破坏那些试图让翻译键不那么难受的 IDE 工具。它也会让查找和清理旧翻译键变得极其困难。
// 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 这样的工具,那样你根本不需要翻译键:
// app/[lang]/checkout/page.jsx
<T>Pay now</T>区域设置检测指的是:在用户明确切换之前,先决定用户应该看到哪种语言。
这通常需要以下几种组合:
Accept-Language 请求头在普通的 Next.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 时,初始检测和重定向层都会为你处理好。
不同地区对日期、数字和货币的格式各不相同。
例如:
04/05/2026 可能表示 5 月 4 日,也可能表示 4 月 5 日€1,999.00 和 1.999,00 € 都是有效格式,具体取决于地区语言在原生 JavaScript 里,你需要用 Intl 自己处理这些:
// 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,这些都会替你处理好:
const someTimestamp = new Date('2026-04-13T09:00:00Z');
const someMoney = {
amount: 1999,
currency: 'EUR',
};
<T>My text with {{ someTimestamp }} and {{ someMoney }}</T>不要把翻译后的 UI 通过拼接碎片来构建:
// bad-string-joining.jsx
const clickHereText = t('click.here');
const toGetStartedText = t('to.get.started')
<p><a href="#">{clickHereText}</a> {toGetStartedText}.</p>这在多种语言里会出问题。
你可能会用英语说“👉👉Click here👈👈 to get started”,但在法语里,更自然的说法是“Pour commencer, 👉👉cliquez ici👈👈”。像日语这样的语言,甚至需要在前后都加上词语,比如“始めるには👉👉こちら👈👈をクリックしてください”。
句子结构可能会变化,因此把它拆成碎片会让翻译变得困难得多。
完整的句子更容易翻译,因为译者可以自然地调整词序,并把意思作为一个完整单元来理解。
如果你正在翻译这样的 JSX:
// rich-text-message.jsx
<p>
Something <strong>that we want to be bold</strong>
</p>你需要查阅你的 i18n 库文档,了解如何处理这一点,因为不同库的处理方式差异很大。
如果你正在使用 18ways,只需像平常一样直接翻译整个 JSX 块即可。
// rich-text-message.jsx
<p>
<T>Something <strong>that we want to be bold</strong></T>
</p>变量让你既能保留完整句子,又能在运行时插入值。
大多数 i18n 库都以某种形式支持这一点。使用 18ways:
// app/[lang]/page.jsx
<T>Hello {{ name: 'Ada' }}</T>这种模式非常适合:
句子依然通顺,值也依然明确。
如果你还需要把变量本身也翻译掉,记得把它包进 t(...) 里:
const animal = t('dog');
<T>Favourite animal: {{ animal }}</T>复数规则因语言而异。英语只有一种复数形式(1 year、2 years 等)。波兰语有多种复数形式(1 rok、2 lata、5 lat)。日语则根本没有复数形式(1 年、2 年、3 年)。
大多数 i18n 库都允许你指定类似 ICU 的语法来处理复数:
// app/[lang]/page.jsx
<T>
{{
unreadCount,
format:
'plural, =0{No unread messages} =1{One unread message} other{{unreadCount} unread messages}',
}}
</T>上面的方法在 18ways 中可行,但在大多数情况下,你也可以直接这样做:
// app/[lang]/page.jsx
<T>{{ unreadCount }} unread messages</T>18ways 会帮你处理复数形式!
为 Next.js 应用添加多种语言并不一定要变成一次重写工程。
如果你没有使用像 18ways 这样的方案——请确保路由和 SSR 正常,然后逐步把文案移到翻译键中,同时务必避开常见的 i18n 陷阱。
如果你正在使用 18ways,那么这些都会为你处理好,你只需要开始把文本包裹在 <T> 块中!