iT邦幫忙

2025 iThome 鐵人賽

DAY 26
0
Modern Web

在Vibe Coding 時代一起來做沒有AI感的漂亮網站吧!系列 第 26

怎麼做多語系網站? 貼上官網安裝方式出 bug? Next.js 15 + next-intl 安裝與使用超完整攻略

  • 分享至 

  • xImage
  •  

嗨咿,我是 illumi!昨天介紹了多語系網站的工具,今天號召所有用Next.js App 的大朋友小朋友,一起來用 next-intl 實踐語言切換吧!

https://ithelp.ithome.com.tw/upload/images/20250927/20178506J3iNKvd2bs.png

1. 按照官網安裝

next-intl

https://ithelp.ithome.com.tw/upload/images/20250926/20178506g6BXmntaVg.png

首先來看看官網吧!但等等......我按照官網裝怎卡住了!

2. 官網設定vs實際操作的差異

官網的設定問題

官網

├── messages
│   ├── en.json
│   └── ...
├── next.config.ts
└── src
    ├── i18n
    │   └── request.ts
    └── app
        ├── layout.tsx
        └── page.tsx

因為最新的Next版本沒有src ,所以採用下面的資料夾架構

project/
├── app/
│   ├── layout.tsx          # 根 layout
│   └── [locale]/
│       ├── layout.tsx      # 語言 layout
│       ├── page.tsx        # 首頁 (記得把app裡面原有的page刪掉)
│       └── 頁面名稱/        
│           └── page.tsx      # 其他頁面
├── i18n/
│   ├── routing.ts          # 路由設定
│   └── request.ts          # 請求設定
├── messages/
│   ├── en.json             # 英文翻譯
│   └── zh.json                   # 中文翻譯
├── middleware.ts                 # 中間件
└── next.config.ts               # Next.js 設定

按照 next-intl 官網的基本設定,會遇到以下問題:

問題 1:setRequestLocale 的使用

官網省略setRequestLocale(locale),但這可以讓 server-side 組件正確獲取語言。

問題 2:沒有 Middleware

沒辦法幫你直接導向語言
middleware 負責處理語言協商和重定向,沒有它就無法正確導向。

完整設定

每個檔案我都再放了一次檔案結構並標注(貼心吧~)

文件結構

project/
├── app/
│   ├── layout.tsx          # 根 layout
│   └── [locale]/
│       ├── layout.tsx      # 語言 layout
│       ├── page.tsx        # 首頁 (記得把app裡面原有的page刪掉)
│       └── 頁面名稱/        
│           └── page.tsx      # 其他頁面
├── i18n/
│   ├── routing.ts          # 路由設定
│   └── request.ts          # 請求設定
├── messages/
│   ├── en.json             # 英文翻譯
│   └── zh.json                   # 中文翻譯
├── middleware.ts                 # 中間件
└── next.config.ts               # Next.js 設定

1. 安裝套件

npm install next-intl

2. next.config.ts

project/
├── app/
│ ├── layout.tsx # 根 layout
│ └── [locale]/
│ ├── layout.tsx # 語言 layout
│ ├── page.tsx # 首頁 (記得把app裡面原有的page刪掉)
│ └── 頁面名稱/
│ └── page.tsx # 其他頁面
├── i18n/
│ ├── routing.ts # 路由設定
│ └── request.ts # 請求設定
├── messages/
│ ├── en.json # 英文翻譯
│ └── zh.json # 中文翻譯
├── middleware.ts # 中間件
└── next.config.ts # Next.js 設定

import createNextIntlPlugin from 'next-intl/plugin';

const withNextIntl = createNextIntlPlugin('./i18n/request.ts');

/** @type {import('next').NextConfig} */
const nextConfig = {};

export default withNextIntl(nextConfig);

3. i18n/routing.ts

project/
├── app/
│ ├── layout.tsx # 根 layout
│ └── [locale]/
│ ├── layout.tsx # 語言 layout
│ ├── page.tsx # 首頁 (記得把app裡面原有的page刪掉)
│ └── 頁面名稱/
│ └── page.tsx # 其他頁面
├── i18n/
│ ├── routing.ts # 路由設定
│ └── request.ts # 請求設定
├── messages/
│ ├── en.json # 英文翻譯
│ └── zh.json # 中文翻譯
├── middleware.ts # 中間件
└── next.config.ts # Next.js 設定

import { defineRouting } from 'next-intl/routing';
import { createNavigation } from 'next-intl/navigation';

export const routing = defineRouting({
  // A list of all locales that are supported
  locales: ['en', 'zh'],

  // Used when no locale matches
  defaultLocale: 'en'
});

export const { Link, redirect, usePathname, useRouter } =
  createNavigation(routing);

4. i18n/request.ts

project/
├── app/
│ ├── layout.tsx # 根 layout
│ └── [locale]/
│ ├── layout.tsx # 語言 layout
│ ├── page.tsx # 首頁 (記得把app裡面原有的page刪掉)
│ └── 頁面名稱/
│ └── page.tsx # 其他頁面
├── i18n/
│ ├── routing.ts # 路由設定
│ └── **request.ts ** # 請求設定
├── messages/
│ ├── en.json # 英文翻譯
│ └── zh.json # 中文翻譯
├── middleware.ts # 中間件
└── next.config.ts # Next.js 設定

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 "en" | "zh")) {
    locale = routing.defaultLocale;
  }

  return {
    locale,
    messages: (
      await import(`../messages/${locale}.json`).catch(() => 
        import(`../messages/${routing.defaultLocale}.json`)
      )
    ).default
  };
});

5. middleware.ts

project/
├── app/
│ ├── layout.tsx # 根 layout
│ └── [locale]/
│ ├── layout.tsx # 語言 layout
│ ├── page.tsx # 首頁 (記得把app裡面原有的page刪掉)
│ └── 頁面名稱/
│ └── page.tsx # 其他頁面
├── i18n/
│ ├── routing.ts # 路由設定
│ └── request.ts # 請求設定
├── messages/
│ ├── en.json # 英文翻譯
│ └── zh.json # 中文翻譯
├── middleware.ts # 中間件
└── next.config.ts # Next.js 設定

import createMiddleware from 'next-intl/middleware';
import { routing } from './i18n/routing';

export default createMiddleware(routing);

export const config = {
  matcher: ['/', '/(zh|en)/:path*']
};

6. app/layout.tsx

這個就變成單純傳遞頁面,而把最主要layout的功能移到[locale]/layout.tsx

project/
├── app/
│ ├── layout.tsx # 根 layout
│ └── [locale]/
│ ├── layout.tsx # 語言 layout
│ ├── page.tsx # 首頁 (記得把app裡面原有的page刪掉)
│ └── 頁面名稱/
│ └── page.tsx # 其他頁面
├── i18n/
│ ├── routing.ts # 路由設定
│ └── request.ts # 請求設定
├── messages/
│ ├── en.json # 英文翻譯
│ └── zh.json # 中文翻譯
├── middleware.ts # 中間件
└── next.config.ts # Next.js 設定

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return children;
}

7. app/[locale]/layout.tsx

project/
├── app/
│ ├── layout.tsx # 根 layout
│ └── [locale]/
│ ├── layout.tsx # 語言 layout
│ ├── page.tsx # 首頁 (記得把app裡面原有的page刪掉)
│ └── 頁面名稱/
│ └── page.tsx # 其他頁面
├── i18n/
│ ├── routing.ts # 路由設定
│ └── request.ts # 請求設定
├── messages/
│ ├── en.json # 英文翻譯
│ └── zh.json # 中文翻譯
├── middleware.ts # 中間件
└── next.config.ts # Next.js 設定

import { NextIntlClientProvider } from 'next-intl';
import { getMessages } from 'next-intl/server';
import { setRequestLocale } from 'next-intl/server';

export default async function LocaleLayout({
  children,
  params
}: {
  children: React.ReactNode;
  params: Promise<{ locale: string }>;
}) {
  const { locale } = await params;
  setRequestLocale(locale);

  const messages = await getMessages();

  return (
    <html lang={locale}>
      <body>
        <NextIntlClientProvider messages={messages}>
          {children}
        </NextIntlClientProvider>
      </body>
    </html>
  );
}

8. app/[locale]/page.tsx

project/
├── app/
│ ├── layout.tsx # 根 layout
│ └── [locale]/
│ ├── layout.tsx # 語言 layout
│ ├── page.tsx # 首頁 (記得把app裡面原有的page刪掉)
│ └── 頁面名稱/
│ └── page.tsx # 其他頁面
├── i18n/
│ ├── routing.ts # 路由設定
│ └── request.ts # 請求設定
├── messages/
│ ├── en.json # 英文翻譯
│ └── zh.json # 中文翻譯
├── middleware.ts # 中間件
└── next.config.ts # Next.js 設定

官網:

 
export default function HomePage() {
  const t = useTranslations('HomePage');
  return <h1>{t('title')}</h1>;
}

我:

import { getTranslations, setRequestLocale } from 'next-intl/server';

export default async function HomePage({
  params
}: {
  params: Promise<{ locale: string }>;
}) {
  const { locale } = await params;
  setRequestLocale(locale);

  const t = await getTranslations('HomePage'); //假設你要取用json中在HomePage物件中的文字

  return (
    <div>
      <h1>{t('title')}</h1>
      <p>{t('about')}</p>
    </div>
  );
}

3. 使用 shadcn Select 切換語言

這邊可以換成其他UI元件庫,或者你要手刻

安裝 shadcn/ui

npx shadcn@latest init

shadcn官網

安裝 shadcn/ui Select

npx shadcn@latest add select

創建語言切換組件

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

import { useRouter, usePathname } from '@/i18n/routing';
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
import { useParams } from 'next/navigation';

const languages = [
  { value: 'en', label: 'Eng' },
  { value: 'zh', label: '中' }
];

export default function LanguageSwitcher() {
  const router = useRouter();
  const pathname = usePathname();
  const params = useParams();
  const currentLocale = params.locale as string;

  const handleLanguageChange = (newLocale: string) => {
    router.replace(pathname, { locale: newLocale });
  };

  const currentLanguage = languages.find(lang => lang.value === currentLocale);

  return (
    <Select value={currentLocale} onValueChange={handleLanguageChange}>
      <SelectTrigger className="w-[140px]">
        <SelectValue>
          <span className="flex items-center gap-2">
            {currentLanguage?.flag} {currentLanguage?.label}
          </span>
        </SelectValue>
      </SelectTrigger>
      <SelectContent>
        {languages.map((language) => (
          <SelectItem key={language.value} value={language.value}>
            <span className="flex items-center gap-2">
              {language.flag} {language.label}
            </span>
          </SelectItem>
        ))}
      </SelectContent>
    </Select>
  );
}

在頁面中使用

// app/[locale]/page.tsx
import { getTranslations } from "next-intl/server";

export default async function HomePage({
  params,
}: {
  params: Promise<{ locale: string }>;
}) {
  const t = await getTranslations("HomePage");

  return (
    <div>
      <h1>{t("title")}</h1>
    </div>
  );
}

4. 常見的翻譯對照表

messages/en.json

project/
├── app/
│ ├── layout.tsx
│ └── [locale]/
│ ├── layout.tsx
│ └── page.tsx
├── i18n/
│ ├── routing.ts
│ └── request.ts
├── messages/
│ ├── ** en.json **
│ └── zh.json
├── middleware.ts
└── next.config.ts

{
"HomePage": {
"title": "Welcome to Our Website",
"about": "This is an amazing platform that helps you achieve your goals.",
"getStarted": "Get Started",
"learnMore": "Learn More"
},
"Navigation": {
"home": "Home",
"about": "About",
"services": "Services",
"contact": "Contact",
"login": "Login",
"logout": "Logout"
},
"Common": {
"loading": "Loading...",
"error": "Something went wrong",
"success": "Success!",
"cancel": "Cancel",
"confirm": "Confirm",
"save": "Save",
"delete": "Delete",
"edit": "Edit",
"search": "Search",
"filter": "Filter",
"sort": "Sort",
"previous": "Previous",
"next": "Next",
"close": "Close"
},
"Form": {
"name": "Name",
"email": "Email",
"password": "Password",
"confirmPassword": "Confirm Password",
"phone": "Phone Number",
"address": "Address",
"submit": "Submit",
"reset": "Reset",
"required": "This field is required",
"invalidEmail": "Please enter a valid email address",
"passwordTooShort": "Password must be at least 8 characters",
"passwordMismatch": "Passwords do not match"
},
"User": {
"profile": "Profile",
"settings": "Settings",
"preferences": "Preferences",
"account": "Account",
"billing": "Billing",
"notifications": "Notifications",
"privacy": "Privacy",
"security": "Security"
},
"Time": {
"today": "Today",
"yesterday": "Yesterday",
"tomorrow": "Tomorrow",
"thisWeek": "This Week",
"thisMonth": "This Month",
"thisYear": "This Year",
"minute": "minute",
"minutes": "minutes",
"hour": "hour",
"hours": "hours",
"day": "day",
"days": "days",
"week": "week",
"weeks": "weeks",
"month": "month",
"months": "months",
"year": "year",
"years": "years"
},
"Status": {
"active": "Active",
"inactive": "Inactive",
"pending": "Pending",
"approved": "Approved",
"rejected": "Rejected",
"completed": "Completed",
"inProgress": "In Progress",
"cancelled": "Cancelled"
}
}

messages/zh.json

project/
├── app/
│ ├── layout.tsx
│ └── [locale]/
│ ├── layout.tsx
│ └── page.tsx
├── i18n/
│ ├── routing.ts
│ └── request.ts
├── messages/
│ ├── en.json
│ └── ** zh.json **
├── middleware.ts
└── next.config.ts

{
  "HomePage": {
    "title": "歡迎來到我們的網站",
    "about": "這是一個幫助您實現目標的絕佳平台。",
    "getStarted": "開始使用",
    "learnMore": "了解更多"
  },
  "Navigation": {
    "home": "首頁",
    "about": "關於我們",
    "services": "服務項目",
    "contact": "聯絡我們",
    "login": "登入",
    "logout": "登出"
  },
  "Common": {
    "loading": "載入中...",
    "error": "發生錯誤",
    "success": "成功!",
    "cancel": "取消",
    "confirm": "確認",
    "save": "儲存",
    "delete": "刪除",
    "edit": "編輯",
    "search": "搜尋",
    "filter": "篩選",
    "sort": "排序",
    "previous": "上一頁",
    "next": "下一頁",
    "close": "關閉"
  },
  "Form": {
    "name": "姓名",
    "email": "電子郵件",
    "password": "密碼",
    "confirmPassword": "確認密碼",
    "phone": "電話號碼",
    "address": "地址",
    "submit": "提交",
    "reset": "重設",
    "required": "此欄位為必填",
    "invalidEmail": "請輸入有效的電子郵件地址",
    "passwordTooShort": "密碼長度至少需要8個字元",
    "passwordMismatch": "密碼不一致"
  },
  "User": {
    "profile": "個人檔案",
    "settings": "設定",
    "preferences": "偏好設定",
    "account": "帳戶",
    "billing": "帳單",
    "notifications": "通知",
    "privacy": "隱私",
    "security": "安全性"
  },
  "Time": {
    "today": "今天",
    "yesterday": "昨天",
    "tomorrow": "明天",
    "thisWeek": "本週",
    "thisMonth": "本月",
    "thisYear": "今年",
    "minute": "分鐘",
    "minutes": "分鐘",
    "hour": "小時",
    "hours": "小時",
    "day": "天",
    "days": "天",
    "week": "週",
    "weeks": "週",
    "month": "月",
    "months": "月",
    "year": "年",
    "years": "年"
  },
  "Status": {
    "active": "啟用",
    "inactive": "停用",
    "pending": "待處理",
    "approved": "已核准",
    "rejected": "已拒絕",
    "completed": "已完成",
    "inProgress": "處理中",
    "cancelled": "已取消"
  }
}

最終就可以像這樣切換

貼上官網安裝方式出 bug?
next-intl

Yes

那我們明天再來講,如果想在裡面塞多行文字怎麼辦呢?


上一篇
網站想寫多個語言該用什麼工具?next-intl、next-i18next、 Intlayer、react-i18next、react-intl、LinguiJS 比較
系列文
在Vibe Coding 時代一起來做沒有AI感的漂亮網站吧!26
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言