website logogarylin.dev

搜尋文章

搜尋文章標題、描述、分類或標籤

多國語言支持指南 - 使用 next-intl 在 Next.js 中實現國際化

2025/02/12Code
Next.jsi18nnext-intl

為什麼我需要做國際化?

純粹是看到一個叫 投胎抽卡機 的網站很有趣,它的功能是可以重複投胎讓你看看投胎了幾次才能到台灣,其中又有幾次會是中國,我覺得很有趣,所以就參照它的功能做了一個多國語言版本的,同時增加了印度的次數,畢竟印度跟中國都是人口大國阿哈哈,成品在這 Life Restart

結果一研究才發現,國際化(i18n)要考慮的東西超多:

  • 文字翻譯當然是基本的
  • URL 路由要支援語言切換
  • 日期時間格式不同國家不一樣 (但我這次要做的專案不需要擔心XD)
  • 數字顯示格式也有差異 (我這次也沒特別針對這個進行就是了)
  • 甚至連文字方向都可能不同

試了幾個方案後,發現 next-intl 最適合 Next.js 的 App Router,設定簡單又功能完整。

什麼是 next-intl?

next-intl 是專門為 Next.js 設計的國際化解決方案,跟 Next.js 13+ 的 App Router 整合得很好。

為什麼選擇它?

  • 無縫整合:跟 Next.js 配合得天衣無縫
  • 效能優秀:支援 SSG 和 SSR,不影響載入速度 (官方說法,我覺得其實有變慢)
  • 路由靈活:可以用 /en/about 或 en.example.com 這種方式
  • 功能豐富:不只翻譯,連日期、數字格式化都內建

最重要的是我覺得對於開發多國語言功能的新手也很容易上手啦!

開始設定 next-intl

1. 安裝套件

bash
pnpm install next-intl

2. 建立檔案結構

我建議按照這個結構來組織:

plaintext
├── messages/          # 翻譯文件
│   ├── en.json
│   └── zh-TW.json
├── src/
│   ├── i18n/
│   │   ├── routing.ts    # 路由設定
│   │   └── request.ts    # 請求設定
│   ├── middleware.ts     # 中間件
│   └── app/
│       └── [locale]/     # 動態語言路由

3. 建立翻譯檔案

先建立最基本的翻譯文件:

messages/en.json
json
{
    "HomePage": {
        "title": "Welcome to my website",
        "description": "This is a multilingual website",
        "nav": {
            "home": "Home",
            "about": "About",
            "contact": "Contact"
        }
    }
}
messages/zh-TW.json
json
{
    "HomePage": {
        "title": "歡迎來到我的網站",
        "description": "這是一個多語言網站",
        "nav": {
            "home": "首頁",
            "about": "關於我們",
            "contact": "聯絡我們"
        }
    }
}

4. 設定路由配置

src/i18n/routing.ts
typescript
import { createNavigation } from 'next-intl/navigation';
import { defineRouting } from 'next-intl/routing';
 
export const routing = defineRouting({
    locales: ['en', 'zh-TW'],
    defaultLocale: 'zh-TW', // 我的網站主要還是面向台灣用戶
});
 
export const { Link, redirect, usePathname, useRouter, getPathname } = createNavigation(routing);

5. 設定中間件

src/middleware.ts
typescript
import createMiddleware from 'next-intl/middleware';
import { routing } from './i18n/routing';
 
export default createMiddleware(routing);
 
export const config = {
    matcher: ['/((?!api|_next/static|favicon.ico).*)'],
};

6. 設定請求配置

src/i18n/request.ts
typescript
import { getRequestConfig } from 'next-intl/server';
import { routing } from './routing';
 
export default getRequestConfig(async ({ requestLocale }) => {
    let locale = await requestLocale;
 
    // 確保語言是支援的
    if (!locale || !routing.locales.includes(locale as any)) {
        locale = routing.defaultLocale;
    }
 
    return {
        locale,
        messages: (await import(`../../messages/${locale}.json`)).default,
    };
});

修改 Next.js 設定

next.config.ts
typescript
import createNextIntlPlugin from 'next-intl/plugin';
 
const withNextIntl = createNextIntlPlugin();
 
/** @type {import('next').NextConfig} */
const nextConfig = {
    // 你原本的設定
};
 
export default withNextIntl(nextConfig);

建立多語言頁面

Layout 設定

src/app/[locale]/layout.tsx
typescript
import { NextIntlClientProvider } from 'next-intl';
import { getMessages } from 'next-intl/server';
import { notFound } from 'next/navigation';
import { routing } from '@/i18n/routing';
 
export function generateStaticParams() {
    return routing.locales.map((locale) => ({ locale }));
}
 
export default async function LocaleLayout({
    children,
    params: { locale }
}: {
    children: React.ReactNode;
    params: { locale: string };
}) {
    // 檢查語言是否支援
    if (!routing.locales.includes(locale as any)) {
        notFound();
    }
 
    const messages = await getMessages();
 
    return (
        <html lang={locale}>
            <body>
                <NextIntlClientProvider messages={messages}>
                    {children}
                </NextIntlClientProvider>
            </body>
        </html>
    );
}

主頁面

src/app/[locale]/page.tsx
typescript
import { useTranslations } from 'next-intl';
import { Link } from '@/i18n/routing';
 
export default function HomePage() {
    const t = useTranslations('HomePage');
 
    return (
        <div>
            <nav>
                <Link href="/">{t('nav.home')}</Link>
                <Link href="/about">{t('nav.about')}</Link>
                <Link href="/contact">{t('nav.contact')}</Link>
            </nav>
 
            <main>
                <h1>{t('title')}</h1>
                <p>{t('description')}</p>
            </main>
        </div>
    );
}

建立語言切換器

src/components/LanguageSwitcher.tsx
typescript
'use client';
 
import { useLocale } from 'next-intl';
import { useRouter, usePathname } from '@/i18n/routing';
 
export default function LanguageSwitcher() {
    const locale = useLocale();
    const router = useRouter();
    const pathname = usePathname();
 
    const languages = [
        { code: 'zh-TW', name: '繁體中文' },
        { code: 'en', name: 'English' }
    ];
 
    const handleLanguageChange = (newLocale: string) => {
        router.replace(pathname, { locale: newLocale });
    };
 
    return (
        <select
            value={locale}
            onChange={(e) => handleLanguageChange(e.target.value)}
            className="border rounded px-3 py-1"
        >
            {languages.map((lang) => (
                <option key={lang.code} value={lang.code}>
                    {lang.name}
                </option>
            ))}
        </select>
    );
}

進階功能

日期時間格式化

typescript
import { useFormatter } from 'next-intl';
 
export default function DateExample() {
    const format = useFormatter();
    const now = new Date();
 
    return (
        <div>
            <p>日期: {format.dateTime(now, {
                year: 'numeric',
                month: 'long',
                day: 'numeric'
            })}</p>
        </div>
    );
}

數字格式化

typescript
import { useFormatter } from 'next-intl';
 
export default function NumberExample() {
    const format = useFormatter();
    const price = 1234.56;
 
    return (
        <div>
            <p>價格: {format.number(price, {
                style: 'currency',
                currency: 'TWD'
            })}</p>
        </div>
    );
}

帶變數的翻譯

messages/zh-TW.json
json
{
    "UserProfile": {
        "welcome": "歡迎回來,{name}!",
        "itemCount": "您有 {count} 個商品"
    }
}
typescript
const t = useTranslations('UserProfile');
 
// 使用方式
<p>{t('welcome', { name: '小明' })}</p>
<p>{t('itemCount', { count: 5 })}</p>

部署注意事項

Vercel 部署

vercel.json 中加入:

json
{
    "functions": {
        "app/[locale]/route.ts": {
            "maxDuration": 10
        }
    }
}

環境變數

bash
# .env.local
NEXT_PUBLIC_DEFAULT_LOCALE=zh-TW

SEO 優化

別忘記設定每個語言的 metadata:

src/app/[locale]/page.tsx
typescript
import { getTranslations } from 'next-intl/server';
 
export async function generateMetadata({ params: { locale } }: { params: { locale: string } }) {
    const t = await getTranslations({ locale, namespace: 'HomePage' });
 
    return {
        title: t('title'),
        description: t('description'),
    };
}

維護建議

翻譯文件管理

我建議用這種方式組織翻譯:

json
{
    "common": {
        "buttons": {
            "save": "儲存",
            "cancel": "取消"
        }
    },
    "pages": {
        "home": { ... },
        "about": { ... }
    }
}