多語系網站要先決定 URL,再開始翻內容。Astro 的 i18n config 可以定義預設語系、URL
prefix 與缺頁 fallback,但它不會替你判斷「這個頁面到底有沒有翻譯」。route、語言 metadata 和 language
picker 若沒有共用同一份翻譯可用狀態,英文網址可能顯示中文,picker 也可能把讀者送進 404。
這篇會在現有內容站加入繁中與英文 route,並保留前 26 篇文章的 /blog/... URL。版本基準是 Astro
7.1.1;官方文件查證與瀏覽器實測日期為 2026-07-24。
這個網站在 Day 26 之前只有一種語言:
頁面 route
/
/blog
/blog/page/1
/blog/day-25-view-transitions
內容
src/content/blog/day-*.md
語言 metadata
<html lang="zh-Hant">
og:locale = zh_TW
JSON-LD inLanguage = zh-Hant
RSS language = zh-tw
「缺一個英文資料夾」只涵蓋檔案結構,既有 URL 還被很多地方使用。Day 3 的 file-based routing決定頁面位置,Day 15 的動態 route用文章 ID 產生公開網址,Day 20 的 RSS 與 JSON endpoint也把/blog/... 發給外部程式。文章正文裡還有一批已發布內鏈。
翻文案前,要先決定既有繁中 URL 是否全部改成 /zh-tw/...。
這次把繁中設為 default locale,英文使用 /en/:
// astro.config.mjs
export default defineConfig({
i18n: {
locales: ["zh-tw", "en"],
defaultLocale: "zh-tw",
routing: {
prefixDefaultLocale: false,
},
},
});
prefixDefaultLocale: false 代表:
src/pages/,網址不加語系。src/pages/en/,網址會加 /en/。/zh-tw/ 不會成為第二份繁中首頁。本次 production preview 實測回傳 404。設定後的路徑如下:
| 內容 | 繁中 | English |
|---|---|---|
| 首頁 | / |
/en/ |
| i18n demo | /demos/i18n |
/en/demos/i18n |
| 文章列表 | /blog |
尚無翻譯 |
| 文章 | /blog/day-... |
尚無翻譯 |
另一種做法是 prefixDefaultLocale: true,讓繁中與英文都帶 prefix:
| 策略 | URL | 好處 | 成本 |
|---|---|---|---|
| default 不加 prefix | /blog/...、/en/... |
保留既有 URL;default 最短 | 兩種 URL 結構不完全對稱 |
| 全部加 prefix | /zh-tw/...、/en/... |
route 結構對稱 | 舊 URL 要 redirect;canonical、feed 與內鏈都得遷移 |
如果是還沒上線的新站,兩種策略都能成立。這個 capstone 已經用 /blog/...
作為 canonical,沿用既有路徑比 route 形式對稱更實用。這和
Day 16 的 redirect/rewrite是同一類問題:URL 一旦發布,就成為外部契約。
設定 prefixDefaultLocale: false 後,頁面拓撲直接對應 URL:
src/pages/
├── index.astro
├── demos/
│ └── i18n.astro
└── en/
├── index.astro
└── demos/
└── i18n.astro
src/pages/demos/i18n.astro 是繁中,src/pages/en/demos/i18n.astro 是英文。兩個 route 共用 I18nRouteDemo.astro
的版面,但各自傳入語系內容。

這裡的 page route 由資料夾結構決定,沒有使用 [lang] 動態 route,也沒有在既有 auth
middleware 裡自行拆 pathname。Day 23 的 middleware 與 locals已經負責每次 request 的登入狀態。官方 routing 無法表達產品規則時,才需要切到routing: "manual",自行組合 i18n middleware;把兩種責任放進同一支 middleware,locale
redirect 和 session 查詢都會變得更難測。
Astro 提供 getRelativeLocaleUrl(),會依 astro.config.mjs 產生符合 prefix 策略的網址:
---
import { getRelativeLocaleUrl } from 'astro:i18n';
const englishURL = getRelativeLocaleUrl('en', 'demos/i18n'); // /en/demos/i18n/
---
<a href="{englishURL}" hreflang="en" lang="en">English</a>
使用 helper 比自己串 `/en/${pathname}` 穩定。若將來 default prefix 或 locale path
mapping 改變,呼叫端不用各自改字串。
但 helper 只保證 URL 格式正確,不保證頁面存在。getRelativeLocaleUrl('en', 'blog') 可以產生/en/blog/,目前專案卻沒有這條 route。
因此,專案另外維護一份已翻譯 route:
const translatedRouteKeys = new Set(["", "demos/i18n"]);
export function hasTranslatedRoute(routeKey: string): boolean {
return translatedRouteKeys.has(routeKey);
}
picker 先把當前 pathname 正規化成不含 locale 的 route key,再查 availability:
const routeKey = getRouteKey(Astro.url.pathname); const translationAvailable = hasTranslatedRoute(routeKey); const
options = locales.map((locale) => ({ locale, href: translationAvailable ? getRelativeLocaleUrl(locale, routeKey) :
undefined, }));
結果是:
/ 與 /en/ 可以互切。/demos/i18n 與 /en/demos/i18n 保留同一頁語意。/blog/... 的 English 顯示 unavailable,不產生假連結。aria-current="page" 表示。picker 是 server-rendered 的普通 <a>,沒有新增 Vue island,也沒有 picker 專屬 JavaScript。整站仍有
Day 25 加入的 ClientRouter,所以同站切換會走 client-side navigation;直接貼/en/demos/i18n 給瀏覽器,也能載入完整 HTML。
「內容怎麼組織」取決於 UI 字串、page route 與長文各自的需求,至少要拆成三層:
| 資料 | 放置位置 | 判準 |
|---|---|---|
| 導覽標籤、按鈕、短提示 | locale dictionary | 短、跨頁重用、key 穩定 |
| 頁面組裝與 route document | src/pages/{locale}/ |
URL 和 file-based routing 的來源 |
| 部落格長文 | Content Collection | 需要 schema、查詢、日期、作者與翻譯關係 |
站名、Header、Footer 與 unavailable 提示放在 src/i18n/index.ts,兩組 demo page 則留在src/pages/。既有 26 篇文章仍是繁中,所有 blog route 也維持原狀。
之後若加入文章翻譯,Day 10 建立的 Content Collection至少需要兩個欄位:
locale: zh-tw
translationKey: day-26-i18n-routing
locale 用來篩選列表、搜尋、RSS 與動態 route;translationKey
把同一篇的不同語言版本配在一起。公開 slug 可以依語言調整,但 translation key 應保持穩定。
day: 26 是系列排序,不能當翻譯 identity;未來若補一篇不屬於鐵人賽的文章,這種配對會立即失效。language
picker 應透過 translation key 查詢另一個 locale entry,不能靠「把 slug 加 /en/」猜譯文。
這個對照專案的語言資料分散在 route、翻譯查找與 language picker。
一個上線中的多語系品牌官網支援四個語系,astro.config.mjs 裡沒有 i18n 區塊。語系改由 src/pages/[...lang]/ 這個 rest
route 承接,路徑清單來自一份手寫的 getStaticPaths:
// src/modules/util.js
const locales = ["en", "cn", "es", "pt"];
export async function getStaticPaths() {
return locales.map((locale) => ({ params: { lang: locale } }));
}
根路徑另外用 Astro.rewrite() 導向預設語系,middleware 則自己從 pathname 切出語系字串。
專案動工時若還沒決定是否採用官方 i18n
routing,先用動態 route 支援四個語系,當下需要做的決定較少。後續成本是「同一件事有幾個來源」。這個專案並存三套翻譯查找方式:.astro
檔用一個自寫的 t(key) 查 JSON、Vue
island 用 vue-i18n、另有一個元件內建自己的字典。語言清單也有兩份,一份在 i18n 模組裡,另一份在 language
picker 元件裡各自硬寫。
沒有單一來源,缺 key 就不會明確失敗。這個專案的語系 JSON,英文有 89 個 key,其他三個語系各 83 個。查不到時,包裝函式會回傳字串NONE:
// 查不到就退回英文,英文也沒有就回傳 'NONE'
if (_get === key) return defText ? defText : "NONE";
因此,「這個語系少了六個字串」不會讓 build 失敗,而會變成正式頁面上的 NONE。language
picker 也不檢查翻譯是否存在,切換時直接改寫 window.location.href,讓頁面完整重載。
getRelativeLocaleUrl()
會計算 prefix,並把「網址怎麼組」集中成一個來源。再配一份明確的 availability 查詢,缺頁會顯示 unavailable,不會產生假連結。語言清單、<html lang>、og:locale
集中成一份 mapping 也是同一個做法:新增語系時只改一個地方,不必搜尋還有哪些表沒有同步。
原本 BaseLayout 把語言寫死成繁中。新增 /en/ 後如果不調整,畫面雖然是英文,產出的 metadata 仍會是:
<html lang="zh-Hant">
<meta property="og:locale" content="zh_TW" />
</html>
瀏覽器、搜尋引擎與輔助科技會收到錯誤的語言訊號。專案用一份 mapping 定義 locale 在各協定中的格式:
const localeMeta = {
"zh-tw": {
htmlLang: "zh-Hant",
ogLocale: "zh_TW",
},
en: {
htmlLang: "en",
ogLocale: "en_US",
},
};
URL segment、HTML language tag 與 Open Graph
locale 的格式不一定相同。集中成一份 mapping 後,每個格式都有明確來源,也不會把同一個字串套進所有欄位。
BaseLayout 再依 Astro.currentLocale 更新:
<html lang>
og:locale
inLanguage
rel="alternate"/hreflang

production preview 的 direct load 結果如下:
| Route | html lang |
canonical pathname | og:locale |
JSON-LD inLanguage |
|---|---|---|---|---|
/ |
zh-Hant |
/ |
zh_TW |
zh-Hant |
/en/ |
en |
/en/ |
en_US |
en |
/demos/i18n |
zh-Hant |
/demos/i18n/ |
zh_TW |
zh-Hant |
/en/demos/i18n |
en |
/en/demos/i18n/ |
en_US |
en |
首頁和 demo 都有繁中、英文與 x-default alternate。/blog 沒有英譯,因此不輸出假的英文 alternate。RSS 也維持單一繁中/rss.xml,<language> 仍是 zh-tw,item link 仍指向/blog/...;沒有文章內容時,先產一份空的英文 feed 沒有讀者收益。
i18n 缺頁行為可以分成四種:
| 機制 | 何時發生 | URL 是否改變 | 適用情境 |
|---|---|---|---|
| default locale redirect | 進入 / |
會 | 所有語系都有 prefix,根路徑要導向 default |
| fallback redirect | 某語系缺頁 | 會切到 fallback URL | 希望讀者清楚知道改看另一種語言 |
| fallback rewrite | 某語系缺頁 | 不變 | 接受語系 URL 與畫面語言可能不同 |
| 不設 fallback | 某語系缺頁 | 404 | 翻譯覆蓋率要明確,不隱藏缺頁 |
redirectToDefaultLocale 只有在 prefixDefaultLocale: true 時有意義,用來決定 / 是否導向/{defaultLocale}。這個專案的繁中 route 沒有 prefix,所以不需要它。
fallback 則處理「某個語系缺少特定頁面」。Astro 7 的 fallbackType 預設是 redirect;設成 rewrite 時,static
build 會在原 locale URL 產出 fallback 內容,瀏覽器網址不變。
這個專案沒有設定 fallback,因為英文目前只有首頁與 i18n demo。若把 /en/blog/day-25-view-transitions
rewrite 成繁中文章,就會出現英文 URL、中文正文、英文 <html lang> 的矛盾;若改 metadata,又變成每個 fallback
route 都要判斷實際內容語言。
實測三個缺頁,結果都是 404:
/zh-tw/ 404
/en/blog/ 404
/en/demos/missing/ 404
Fallback 仍有適用情境。翻譯覆蓋率高、少數頁面延遲上線時,redirect 到 fallback 語系可能比 404 友善;rewrite 則要確認 canonical、lang
與使用者提示都能誠實反映內容。先用表格判斷,再選 config,不要把 fallback 當「順手開著比較完整」。
Astro 有 preferred locale 相關 API,也能從 request 的 Accept-Language
判斷瀏覽器偏好。不過「瀏覽器設定成英文」不等於「這次一定想看英文」:共用裝置、語言學習或系統預設都可能讓兩者不同。
Day
26 只提供手動 picker,不自動 redirect,也不把語言偏好寫入 cookie 或會員資料。讀者明確點了哪個語言,URL 就反映哪個語言。
若產品未來需要記住選擇,再把問題拆開:
Accept-Language?這四個問題確定後,再決定 middleware 如何處理偏好。
language picker 的驗收除了「點得動」,還要確認 URL、內容與 metadata 是否同步。本次從繁中 demo 切到英文,先在 window
放一個 marker,再檢查 Navigation Timing:
{
"path": "/en/demos/i18n/",
"lang": "en",
"canonical": "/en/demos/i18n/",
"og": "en_US",
"jsonLd": "en",
"active": ["i18n demo", "English"],
"marker": "persists",
"navigationEntries": 1
}
marker 保留、document navigation
entry 仍是 1,表示這次由 ClientRouter 接手,沒有完整 reload;URL、H1、canonical、語言 metadata 與 picker active
state 則全部換成英文。
接著按 back 回 /demos/i18n/,lang 恢復 zh-Hant,active 變回繁體中文;forward 再回英文,狀態仍一致。direct
load 另外用新 browser session 驗過,沒有依賴「必須先從繁中點過來」。
行動版在 390×844 實測兩種語系:
{
"innerWidth": 390,
"scrollWidth": 390,
"overflow": false,
"pickerVisible": true
}
production build 產出 /demos/i18n/、/en/ 與/en/demos/i18n/;原本的 blog、RSS、搜尋與文章 route 仍能預渲染。整輪 language switch、back/forward 與 direct
load 的 browser console errors、page errors 都是 0。
/zh-tw/ 不產生重複首頁。/blog 的 English 顯示 unavailable。/en/blog/ 未被 fallback 成中文,實際回 404。<html lang>、canonical、Open Graph、JSON-LD 與 hreflang 隨 route 切換。/blog/... item links 未改。route、內容 identity 與 picker 若各自維護規則,就可能出現不一致。這個實作先沿用已發布 URL,再把短 UI 字串、page
route 與長文 collection 分開管理。language
picker 只替確實存在的翻譯產生連結;加入第二種語言後,缺頁會明確失敗,不會被 fallback 隱藏。
Day 27 會接著拆解 astro.config 裡的 integrations、prefetch 與 dev
toolbar:它們各自改變哪一層行為,以及哪些設定真的需要。
官方查證:Internationalization guide、Configuration reference:i18n、
astro:i18nmodule。