[CODE]

Astro i18n: The Gaps You Fill When CI Publishes Into Two Locales

Code & Cast runs CI pipelines that commit articles into both en/ and zh-TW/. Every gap in Astro's i18n layer that forced custom code: the trailing slash deadlock, content collection's missing translation model, entry ID lowercasing, and TypeScript enforcement at build time.

6 min read AI-generated
astro i18n typescript web

Code & Cast exists to document two things: engineering work and fishing trips from Taiwan. Both sections are fed by automated CI pipelines that generate articles and commit them into src/content/blog/en/ and src/content/blog/zh-TW/. The readers who arrive at each locale don’t overlap — Taiwanese engineers read in Mandarin, English-speaking developers don’t read Mandarin. One domain, one codebase, two locales, with pipelines that publish into both continuously.

That architecture made i18n infrastructure a prerequisite, not a later-phase concern. If the routing layer breaks, CI-generated content reaches neither audience. Getting this right once was the only viable option for a site running without human editorial intervention.

Astro’s i18n routing is the correct tool. The docs are complete. I still lost an afternoon to a config option I hadn’t touched.

Chrome’s network tab showed a redirect loop: /zh-TW/blog cycling indefinitely. Middleware was clean. Explicit redirects were clean. The cause was a default I’d never set:

trailingSlash: 'ignore'

Astro’s internal mechanism redirects /zh-TW/blog to /zh-TW/blog/ in development. That behavior collides with the i18n routing layer — both sides wait for the other to resolve first. Setting trailingSlash to 'ignore' removes the conflict entirely: neither layer attempts a slash correction, so the deadlock doesn’t form.

// astro.config.mjs
export default defineConfig({
  trailingSlash: 'ignore',
  i18n: {
    defaultLocale: 'en',
    locales: ['en', 'zh-TW'],
    routing: {
      prefixDefaultLocale: false,
    },
  },
})

prefixDefaultLocale: false keeps English at /blog rather than /en/blog. The prefix adds URL length without adding information.

13 files instead of 7 with branches

Each locale gets its own page files:

src/pages/
  index.astro          ← English
  blog/
    index.astro
    [slug].astro
  cast/
    index.astro
    [slug].astro
  about.astro
  404.astro
  zh-TW/
    index.astro        ← Chinese
    blog/
      index.astro
      [slug].astro
    cast/
      index.astro
      [slug].astro
    about.astro

Seven English files, six Chinese files (404 is English-only). Thirteen page files total, instead of seven that branch on locale at render time.

Each file knows its locale when you open it. No Astro.currentLocale inspection, no conditional logic deciding which repository query to run. The duplication is mechanical — copy the pattern, change the locale constant. When nothing is broken, 13 files and 7 files feel equivalent. When something breaks at a bad hour, tracing a file list beats tracing a branch tree.

Entry IDs lowercase your folder names

src/content/blog/
  en/
    hello-world.mdx    → ID: en/hello-world.mdx
  zh-TW/
    hello-world.mdx    → ID: zh-tw/hello-world.mdx  ← lowercased

Astro normalizes entry IDs to lowercase. zh-TW/ becomes zh-tw/ in the generated ID. The repository layer has to normalize back before comparing:

async getBySlug(slug: string, lang: Locale) {
  const entries = await getCollection('blog')
  const entry = entries.find((e) => {
    const parts = e.id.replace(/\.mdx?$/, '').split('/')
    const entryLang = parts[0] === 'zh-tw' ? 'zh-TW' : parts[0]
    return entryLang === lang && parts.slice(1).join('/') === slug
  })
  if (!entry) return undefined
  return toArticle(entry)
}

Static site — runs once per slug at build time. Without the parts[0] === 'zh-tw' ? 'zh-TW' : parts[0] normalization, comparing lang === 'zh-TW' against 'zh-tw' returns false for every Chinese article. Every /zh-TW/blog/* route 404s in production. This one passes silently through development because the mismatch only matters when you actually do the comparison.

Content collections have no concept of a translation pair

src/content/blog/
  en/hello-world.mdx
  zh-TW/hello-world.mdx

Same filename, different folder. Astro treats these as two completely unrelated entries — no built-in translation relationship exists anywhere in the framework. The language switcher that sends a reader from /blog/hello-world to /zh-TW/blog/hello-world is built entirely on the convention that translation pairs share filenames. That convention is yours, not the framework’s. Astro won’t enforce it, detect violations, or warn you when a Chinese version is missing for an article CI just published in English.

TypeScript makes a missing translation a build failure

Fourteen UI string keys per locale, all in one file:

export const ui = {
  'en': {
    'nav.blog': 'Engineering',
    'nav.cast': 'Fishing',
    'hero.cta.blog': 'Engineering Articles',
    // 11 more keys
  },
  'zh-TW': {
    'nav.blog': '技術',
    'nav.cast': '釣魚',
    'hero.cta.blog': '技術文章',
    // 11 more keys
  },
} 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
}

as const narrows the type deeply enough that a missing key in zh-TW is a TypeScript error at compile time, not a silent undefined at runtime. Add a string to English, forget the Chinese equivalent — build fails before deployment. Across 13 page files and every shared component, not one missing translation has shipped. The type system catches it before CI has a chance to push broken content.

Detection stops at the root — intentionally

Auto-detection runs only at /. An inline script in index.astro:

if (window.location.pathname === '/') {
  const override = sessionStorage.getItem('lang-override')
  if (override) {
    sessionStorage.removeItem('lang-override')
  } else {
    const lang = navigator.language || 'en'
    if (lang.startsWith('zh')) {
      window.location.replace('/zh-TW/')
    }
  }
}

replace() rather than href = — no history entry. The sessionStorage key lang-override is written by the language switcher in the nav when a user explicitly changes locale. Within a single session, switching to English and navigating home won’t redirect back to Chinese. The key clears when the tab closes, so detection resumes on the next visit — which is correct. Same session: respect the explicit choice. New session: let the browser language decide again.

Past the root, every URL already encodes the locale. Running detection at /blog would mean using a browser-preference guess to override a URL-encoded decision. The only output is edge cases with no upside. Every piece of language preference state you store is a class of bugs you own indefinitely.

Two gaps Astro deliberately leaves open

Missing translation returns 404. If /zh-TW/blog/that-slug doesn’t exist, Astro serves 404 — it won’t fall back to the English version automatically. Whether a missing translation means “this content exists in English only” or “this is an error state” is a product decision only the site owner can answer. Astro correctly declines to choose for you.

URL generation needs to carry locale:

export function getLocalePath(locale: UILocale, path: string): string {
  if (locale === defaultLocale) return path
  return `/zh-TW${path}`
}

Every navigation link in the nav component uses getLocalePath. Without it, the locale prefix logic scatters across every component that generates a URL — hardcoded, inconsistent, and untraceable when structure changes. One function to update if the routing scheme ever shifts.

These aren’t framework omissions. Astro’s job was always routing: which locales exist, how they map to URLs. The application-level decisions — fallback strategy, URL construction, translation enforcement — belong to the layer above.

The trailing slash problem was documented. Just not in the section about trailing slashes.