I needed this site — and a couple of other projects — to support both Traditional Chinese and English. Astro has built-in i18n now, so I figured it would be quick to wire up. It mostly was. But there were a few spots where I lost an afternoon before things clicked.
This is a write-up of what I actually did and what bit me.
The Routing Setup
The config is simple enough:
// astro.config.mjs
export default defineConfig({
trailingSlash: 'ignore',
i18n: {
defaultLocale: 'en',
locales: ['en', 'zh-TW'],
routing: {
prefixDefaultLocale: false,
},
},
})
prefixDefaultLocale: false means English lives at /blog, not /en/blog. Chinese gets /zh-TW/blog. That felt right for a portfolio — the default language shouldn’t have a prefix cluttering up the URLs.
The trailingSlash: 'ignore' line took me a while to add. Without it, Astro’s built-in redirect from /zh-TW/blog to /zh-TW/blog/ (or vice versa) would sometimes conflict with the i18n routing layer and create redirect loops in development. Setting it to 'ignore' just sidesteps the whole issue.
Duplicating Pages Instead of Magic Routing
I went with duplicate page files rather than trying to make one file handle both locales:
src/pages/
blog/
index.astro ← English
[slug].astro
zh-TW/
blog/
index.astro ← Chinese
[slug].astro
It’s more files to maintain, but it’s also dead simple to reason about. Each page knows its locale upfront and can call the right queries. No Astro.currentLocale magic, no conditional rendering based on URL parsing at render time.
Content Collections Per Locale
The actual content files live under locale subfolders:
src/content/blog/
en/
hello-world.mdx
zh-TW/
hello-world.mdx ← same slug, different language
The slug matching between languages is manual — I just use the same filename for both. There’s no built-in “these two files are translations of each other” concept in Astro content collections. If you want cross-language links (like a language switcher on an article page), you have to build that yourself.
The repository layer handles filtering by locale. Each entry ID in Astro’s content collection looks like en/hello-world.mdx, so slicing that apart gives you the locale and the slug:
async getBySlug(slug: string, lang: Locale) {
const entries = await getCollection('blog')
const entry = entries.find((e) => {
const parts = e.id.replace(/\.mdx?$/, '').split('/')
return parts[0] === lang && parts.slice(1).join('/') === slug
})
if (!entry) return undefined
return toArticle(entry)
}
This runs on every slug page render, so it’s not the most efficient thing in the world — but for a static site built at deploy time, it doesn’t matter.
Language Detection
I only do automatic detection on the root path /. The logic is a small inline script in the page <head>:
<Fragment slot="head">
<script is:inline>
if (window.location.pathname === '/') {
const lang = navigator.language || 'en'
if (lang.startsWith('zh')) {
window.location.replace('/zh-TW/')
}
}
</script>
</Fragment>
It’s intentionally minimal. Checking navigator.language on the client and redirecting with replace() (not href =, which adds a history entry) is enough for a personal site. I’m not storing language preference in localStorage or cookies — if you navigate to /blog you’ll get English, and if you want Chinese you click the language switcher. Simple.
The Translation File Pattern
All UI strings live in a single ui.ts file:
export const ui = {
'en': {
'nav.blog': 'Engineering',
'nav.cast': 'Fishing',
'hero.cta.blog': 'Engineering Articles',
// ...
},
'zh-TW': {
'nav.blog': '技術',
'nav.cast': '釣魚',
'hero.cta.blog': '技術文章',
// ...
},
} as const
type TranslationKeys = keyof typeof ui['en']
export function t(locale: UILocale, key: TranslationKeys): string {
return (ui[locale]?.[key] ?? ui['en'][key]) as string
}
The as const gives TypeScript enough type information to catch missing keys at compile time. If you add a key to 'en' but forget 'zh-TW', the t() function falls back to English — which is fine for a site where English is the default.
This pattern scaled well across 5+ pages and a handful of components without any issues.
What Astro i18n Doesn’t Do
A few things I had to handle myself:
No 404 fallback to default locale. If a Chinese article doesn’t exist yet, /zh-TW/blog/some-slug returns a 404. Astro won’t automatically serve the English version. You have to build that logic yourself if you want it.
No slug linking between translations. The content collection has no concept of “this MDX file is the zh-TW version of that English file.” Slug parity by filename convention is the only tool you have out of the box.
getLocalePath is your problem. Every component that generates links needs to know the current locale and prefix accordingly. I extracted this into a helper in ui.ts:
export function getLocalePath(locale: UILocale, path: string): string {
if (locale === defaultLocale) return path
return `/zh-TW${path}`
}
Then every <a href> in components goes through getLocalePath(locale, '/blog') rather than hardcoding /zh-TW/blog. It’s a small thing but without it you end up with locale-specific strings scattered everywhere.
Was It Worth Using the Built-in i18n?
Honestly, yes. Before Astro added native i18n, you had to roll all the routing yourself. Having the config declare your locales and routing strategy means at least the URL structure is handled. The gaps — fallback pages, content relationship between locales, path generation — are real, but they’re also not that many lines of code to fill in yourself.
For a personal site at this scale, it’s a reasonable foundation.