Next.js i18n 多國語言支援
在 App Router 中實作多國語言支援 (Internationalization) 比以往更加靈活。我們可以完全掌控路由結構、翻譯檔案的加載方式,以及如何讓使用者自動導向至最合適的語系。
本篇文章將帶你從零構建一個生產環境等級 (Production-Ready) 的 i18n 系統。
路由結構與 Middleware
最穩健的做法是將「語系」作為 URL 的第一層路徑(Path Segment),例如 /en/about 或 /zh-TW/about。
目錄結構
我們使用 Dynamic Route [lang] 來捕捉語系:
app/
[lang]/
layout.tsx
page.tsx
about/
page.tsx
i18n-config.ts # 設定檔
middleware.ts # 負責語系偵測與導向
dictionaries/ # 翻譯檔案
en.json
zh-TW.json
設定檔 (i18n-config.ts)
先定義好網站支援的語系:
// i18n-config.ts
export const i18n = {
defaultLocale: 'en',
locales: ['en', 'zh-TW', 'ja'], // 支援 英文、繁中、日文
} as const;
export type Locale = (typeof i18n)['locales'][number];
Middleware:精準的語系偵測
當使用者存取根目錄 / 時,我們需要根據請求標頭 Accept-Language 來判斷要把他導向哪裡。
為了精準處理權重(q-factor),我們建議安裝兩個輕量套件:
npm install @formatjs/intl-localematcher negotiator
npm install -D @types/negotiator
middleware.ts 實作:
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { i18n } from './i18n-config';
import { match as matchLocale } from '@formatjs/intl-localematcher';
import Negotiator from 'negotiator';
function getLocale(request: NextRequest): string | undefined {
// 1. 將 header 轉換為 negotiator 能讀的格式
const negotiatorHeaders: Record<string, string> = {};
request.headers.forEach((value, key) => (negotiatorHeaders[key] = value));
// 2. 獲取使用者偏好語言列表 (例如: ['zh-TW', 'zh', 'en'])
const languages = new Negotiator({ headers: negotiatorHeaders }).languages();
// 3. 使用 intl-localematcher 比對最適合的語系
// 它會幫你處理像 zh-HK fallback 到 zh-TW 這種複雜邏輯
return matchLocale(languages, i18n.locales, i18n.defaultLocale);
}
export function middleware(request: NextRequest) {
const pathname = request.nextUrl.pathname;
// 1. 檢查路徑是否已經包含語系 (例如 /en/about)
const pathnameIsMissingLocale = i18n.locales.every(
(locale) => !pathname.startsWith(`/${locale}/`) && pathname !== `/${locale}`
);
// 2. 如果已經有語系,就放行
if (!pathnameIsMissingLocale) return;
// 3. 如果沒有語系,偵測最佳語系並導向
const locale = getLocale(request);
// 導向至對應語系 (例如 /about -> /zh-TW/about)
return NextResponse.redirect(
new URL(`/${locale}${pathname.startsWith('/') ? '' : '/'}${pathname}`, request.url)
);
}
export const config = {
// 忽略內部檔案與靜態資源
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};
翻譯字典加載 (Server Components)
在 Server Components 中,我們可以根據 params.lang 直接從檔案系統讀取 JSON,這是效能最好的方式,因為不需要將所有翻譯包發送到客戶端。
dictionaries/get-dictionary.ts:
import 'server-only'; // 確保這隻檔案只能在 Server 端執行
import type { Locale } from '@/i18n-config';
// 使用動態 import 來實作 Lazy Loading
const dictionaries = {
en: () => import('./en.json').then((module) => module.default),
'zh-TW': () => import('./zh-TW.json').then((module) => module.default),
ja: () => import('./ja.json').then((module) => module.default),
};
export const getDictionary = async (locale: Locale) =>
dictionaries[locale]?.() ?? dictionaries.en();
頁面使用範例 (app/[lang]/page.tsx):
import { getDictionary } from '@/dictionaries/get-dictionary';
import { Locale } from '@/i18n-config';
export default async function Page({ params: { lang } }: { params: { lang: Locale } }) {
const dict = await getDictionary(lang);
return (
<section>
{/* 直接使用字典內容 */}
<h1>{dict.home.title}</h1>
<p>{dict.home.description}</p>
</section>
);
}
Client Components 支援
如果 Client Component (例如:互動按鈕、彈窗) 也需要翻譯文字,該怎麼辦?
❌ 不建議:在 Client Component 中直接 import 整個 JSON,這會導致 Bundle Size 變大。
✅ 推薦做法:由 Server Component 讀取該組件需要的翻譯片段,透過 Props 傳入;或者使用 Context API 提供全域翻譯。
方法 A:透過 Props 傳遞 (推薦)
這最簡單且效能最好。
// Server Component
export default async function Page({ params: { lang } }) {
const dict = await getDictionary(lang);
// 只傳遞需要的翻譯區塊
return <ClientCounter labels={dict.counter} />;
}
// Client Component
('use client');
export default function ClientCounter({ labels }) {
return <button>{labels.increment}</button>;
}
方法 B:使用 React Context (適用於大型應用)
如果你不想層層傳遞 Props,可以建立一個 DictionaryProvider。
// app/[lang]/dictionary-provider.tsx
'use client';
import { createContext, useContext } from 'react';
const DictionaryContext = createContext(null);
export function DictionaryProvider({ dictionary, children }) {
return <DictionaryContext.Provider value={dictionary}>{children}</DictionaryContext.Provider>;
}
export function useDictionary() {
const dictionary = useContext(DictionaryContext);
if (dictionary === null) {
throw new Error('useDictionary must be used within a DictionaryProvider');
}
return dictionary;
}
然後在 layout.tsx 中包覆:
// app/[lang]/layout.tsx
import { getDictionary } from '@/dictionaries/get-dictionary';
import { DictionaryProvider } from './dictionary-provider';
export default async function RootLayout({ children, params: { lang } }) {
const dictionary = await getDictionary(lang);
return (
<html lang={lang}>
<body>
<DictionaryProvider dictionary={dictionary}>{children}</DictionaryProvider>
</body>
</html>
);
}
靜態生成 (SSG)
為了讓含有動態參數 [lang] 的頁面也能被靜態生成(Static Site Generation),我們需要匯出 generateStaticParams。
// app/[lang]/layout.tsx
import { i18n } from '@/i18n-config';
export async function generateStaticParams() {
// 回傳所有支援的語系,讓 Next.js 在 build time 就產生對應的 HTML
// [{ lang: 'en' }, { lang: 'zh-TW' }, { lang: 'ja' }]
return i18n.locales.map((locale) => ({ lang: locale }));
}
export default function RootLayout({ children, params }) {
return (
<html lang={params.lang}>
<body>{children}</body>
</html>
);
}
小結
實作 i18n 的關鍵在於效能與架構的平衡:
- Middleware:利用
negotiator準確判斷使用者語系。 - Server First:盡量在 Server Component 載入翻譯檔案,避免增加 Client Bundle Size。
- Static Params:總是加上
generateStaticParams,確保多語系頁面依然能享受 SSG 的高速優勢。
現在,你的 Next.js 應用已經具備了國際化的能力,且依然保持極佳的效能!