多國語言支持指南 - 使用 next-intl 在 Next.js 中實現國際化
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. 建立翻譯檔案
先建立最基本的翻譯文件:
json
{
"HomePage": {
"title": "Welcome to my website",
"description": "This is a multilingual website",
"nav": {
"home": "Home",
"about": "About",
"contact": "Contact"
}
}
}
json
{
"HomePage": {
"title": "歡迎來到我的網站",
"description": "這是一個多語言網站",
"nav": {
"home": "首頁",
"about": "關於我們",
"contact": "聯絡我們"
}
}
}
4. 設定路由配置
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. 設定中間件
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. 設定請求配置
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 設定
typescript
import createNextIntlPlugin from 'next-intl/plugin';
const withNextIntl = createNextIntlPlugin();
/** @type {import('next').NextConfig} */
const nextConfig = {
// 你原本的設定
};
export default withNextIntl(nextConfig);
建立多語言頁面
Layout 設定
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>
);
}
主頁面
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>
);
}
建立語言切換器
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>
);
}
帶變數的翻譯
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:
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": { ... }
}
}