18ways 部落格
如何在不損害 SEO 或從頭重建的情況下,為現有的 Next.js 應用程式加入多語系 i18n 與在地化。
I18n(國際化)、l10n(在地化)、多語言支援……不管你怎麼稱呼它,最後你都需要更新你的 Next.js 應用,讓它支援不只一種語言。
困難的地方不是翻譯一句話,而是在不破壞 SEO、不把程式碼庫攪得一團亂、也不替自己製造維護負擔的前提下,為一個真實的 Next.js 應用加入多種語言。
If you want to jump straight to the code, check out the 18ways-next GitHub examples .
在做其他事情之前,我們先安裝套件。我們會設定 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 系統都要求你把程式碼拆成翻譯鍵。命名這些鍵時務必要小心。
不好的 key 會很模糊:
// 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 中,通常會長得像這樣:
// 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> 區塊裡!