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 的關鍵在於效能架構的平衡:

  1. Middleware:利用 negotiator 準確判斷使用者語系。
  2. Server First:盡量在 Server Component 載入翻譯檔案,避免增加 Client Bundle Size。
  3. Static Params:總是加上 generateStaticParams,確保多語系頁面依然能享受 SSG 的高速優勢。

現在,你的 Next.js 應用已經具備了國際化的能力,且依然保持極佳的效能!