iT邦幫忙

2026 iThome 鐵人賽

DAY 26
0
Vibe Coding

Vibe Coding的30天,自然語言與AI共舞,從Prompt到高品質原型落地系列 第 26

Day 26|多語系國際化(i18n)next-intl整合與語系切換

  • 分享至 

  • xImage
  •  

在Day25中,我們成功完成了Vercel自動化CI/CD部署與NeonPostgreSQL雲端上雲,讓VibePulse正式進入生產環境。

今天(Day26),我們將為VibePulse注入國際化能力—多語系支援(i18n)!

為了讓產品能服務全球學習者,我們將使用Next.jsAppRouter生態系中最主流且效能極佳的next-intl套件,實作:

語系路由與Middleware配置:支援/zh-TW/dashboard與/en/dashboard語系前綴路由,自動偵測瀏覽器預設語言。

多語系JSON字典檔管理:模組化管理繁體中文(zh-TW.json)與英文(en.json)翻譯辭典。

動態語系切換組件(LanguageSwitcher):無刷新即時切換介面語言,並保持當前頁面狀態。

1.系統架構與路由設計
使用next-intl時,我們的AppRouter目錄結構會稍作調整,將需要國際化的頁面統一包覆在[locale]動態路由資料夾下。
2.實戰步驟1:安裝套件與配置Middleware
在Terminal執行安裝:

npm install next-intl

A.建立i18n/request.ts配置檔

// i18n/request.ts
import { getRequestConfig } from 'next-intl/server';

export const locales = ['zh-TW', 'en'] as const;
export const defaultLocale = 'zh-TW';

export default getRequestConfig(async ({ requestLocale }) => {
  let locale = await requestLocale;

  if (!locale || !locales.includes(locale as any)) {
    locale = defaultLocale;
  }

  return {
    locale,
    messages: (await import(`../messages/${locale}.json`)).default,
  };
});

B.設定middleware.ts語系自動偵測與路由重導向

// middleware.ts
import createMiddleware from 'next-intl/middleware';
import { locales, defaultLocale } from './i18n/request';

export default createMiddleware({
  locales,
  defaultLocale,
  localePrefix: 'always', // 路由永遠帶有語系前綴,如 /zh-TW/dashboard
});

export const config = {
  // 匹配所有需要國際化的路徑,排除 _next、static 與 api
  matcher: ['/((?!api|_next|_vercel|.*\\..*).*)'],
};

3.實戰步驟2:建立多語系字典JSON檔案
在專案根目錄下建立messages/zh-TW.json與messages/en.json:
TW messages/zh-TW.json

{
  "Navigation": {
    "dashboard": "儀表板",
    "skills": "技能圖譜",
    "notes": "學習筆記"
  },
  "Dashboard": {
    "title": "全站數據儀表板",
    "subtitle": "量化你的學習歷程,即時掌握技能累積與練習熱度",
    "kpi": {
      "totalSkills": "總追蹤技能",
      "avgProficiency": "平均熟練度",
      "practiceHours": "累積練習時數",
      "totalNotes": "學習筆記總數"
    },
    "hours": "小時",
    "items": "項",
    "articles": "篇"
  }
}

4.實戰步驟3:配置語系RootLayout(app/[locale]/layout.tsx)
在app/[locale]/layout.tsx中使用NextIntlClientProvider包覆全站,讓Server與ClientComponent皆可存取語系字典:

// app/[locale]/layout.tsx
import { NextIntlClientProvider } from 'next-intl';
import { getMessages } from 'next-intl/server';
import { notFound } from 'next/navigation';
import { locales } from '@/i18n/request';

export default async function LocaleLayout({
  children,
  params: { locale },
}: {
  children: React.ReactNode;
  params: { locale: string };
}) {
  if (!locales.includes(locale as any)) {
    notFound();
  }

  const messages = await getMessages();

  return (
    <html lang={locale}>
      <body className="bg-slate-950 text-slate-100 min-h-screen">
        <NextIntlClientProvider messages={messages}>
          {children}
        </NextIntlClientProvider>
      </body>
    </html>
  );
}

5.實戰步驟4:打造語系切換選單(components/LanguageSwitcher.tsx)
我們建立一個動態切換語系的選單,切換時能精準保留當前路徑:

// components/LanguageSwitcher.tsx
'use client';

import { useLocale } from 'next-intl';
import { usePathname, useRouter } from 'next/navigation';
import { Globe } from 'lucide-react';

export function LanguageSwitcher() {
  const currentLocale = useLocale();
  const router = useRouter();
  const pathname = usePathname();

  const handleLanguageChange = (newLocale: string) => {
    // 替換當前 URL 的語系前綴 (例如 /zh-TW/dashboard -> /en/dashboard)
    const newPath = pathname.replace(`/${currentLocale}`, `/${newLocale}`);
    router.push(newPath);
  };

  return (
    <div className="flex items-center gap-2 bg-slate-900 border border-slate-800 px-3 py-1.5 rounded-xl text-xs font-semibold text-slate-300">
      <Globe className="h-3.5 w-3.5 text-cyan-400" />
      <select
        value={currentLocale}
        onChange={(e) => handleLanguageChange(e.target.value)}
        className="bg-transparent text-slate-200 focus:outline-none cursor-pointer"
      >
        <option value="zh-TW" className="bg-slate-900">繁體中文</option>
        <option value="en" className="bg-slate-900">English</option>
      </select>
    </div>
  );
}

6.實戰步驟5:在頁面中使用多語系辭典(useTranslations)
以app/[locale]/dashboard/page.tsx為例,使用useTranslations或getTranslations讀取對應語系文字:

// app/[locale]/dashboard/page.tsx (Server Component)
import { getTranslations } from 'next-intl/server';
import { getDashboardStats } from '@/lib/dashboard';
import { LanguageSwitcher } from '@/components/LanguageSwitcher';

export default async function DashboardPage() {
  const t = await getTranslations('Dashboard');
  const stats = await getDashboardStats();

  return (
    <div className="max-w-7xl mx-auto px-4 py-8 space-y-8">
      {/* 頂部 Header 與語系切換 */}
      <div className="flex items-center justify-between">
        <div>
          <h1 className="text-3xl font-extrabold text-slate-100">{t('title')}</h1>
          <p className="text-slate-400 text-sm mt-1">{t('subtitle')}</p>
        </div>
        <LanguageSwitcher />
      </div>

      {/* KPI 卡片區域 */}
      <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
        <div className="p-5 rounded-2xl border border-slate-800 bg-slate-900/60">
          <span className="text-xs text-slate-400">{t('kpi.totalSkills')}</span>
          <div className="text-2xl font-black text-slate-100 mt-1">
            {stats.totalSkills} {t('items')}
          </div>
        </div>

        <div className="p-5 rounded-2xl border border-slate-800 bg-slate-900/60">
          <span className="text-xs text-slate-400">{t('kpi.practiceHours')}</span>
          <div className="text-2xl font-black text-slate-100 mt-1">
            {Math.round(stats.totalPracticeMinutes / 60)} {t('hours')}
          </div>
        </div>
      </div>
    </div>
  );
}

今天我們完成了VibePulse的國際化架構:

next-intl路由隔離:透過[locale]動態路由與Middleware,實現無縫的URL語系路由管理。

模組化JSON字典:將全站UI文字抽離至messages/資料夾,支援動態擴充更多語言。

流暢的切換體驗:實作LanguageSwitcher組件,讓使用者能隨時切換繁體中文與英文介面!


上一篇
Day 25|Vercel自動化CI/CD部署與Neon PostgreSQL上雲
下一篇
Day 27|AI 智能助手整合使用Vercel AI SDK與Gemini打造技能教練
系列文
Vibe Coding的30天,自然語言與AI共舞,從Prompt到高品質原型落地28
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言