Manual i18n: what hand-rolling three languages costs

I hand-rolled this site's i18n instead of using Astro's built-in config. Here is why, and exactly where the cost shows up — two 404 incidents and a blog that was half translated.

I did not use Astro’s i18n config; I wrote the whole language layer myself. Not because the framework’s version is bad, but because on this site, multiple languages are a content-management problem before they are a rendering problem. Swapping copy into English is the easy half. Keeping three languages structurally in sync is the hard half.

What I actually wrote

Three pieces:

  1. One [lang] dynamic route. A single page file serves zh, en and ja.
  2. Content split by language directory: src/content/blog/<lang>/<slug>.md, with the locale read off the entry id prefix.
  3. One copy dictionary: LOCALES, LOCALE_META and t(locale, key, vars) in src/lib/i18n.ts.

Route construction has exactly one source of truth: src/server/paths.ts. Pages only export const getStaticPaths = xxxPaths.

Against the framework’s i18n

Framework i18n Hand-rolled
Route prefix generated from config explicit in my [lang] route
Language-detect redirect the framework mine, on /
Behaviour when a translation is missing falls back to the default locale, silently the post does not exist in that language
Types the framework’s locale type my own Locale, narrowable at compile time
Where it breaks inside the framework inside my code, where I can read it

That third row decided it. “Fall back to the default locale” sounds considerate; in practice it means an English reader opens a post and gets Chinese. I would rather have a gap than have a reader wonder whether they clicked the wrong thing.

Silent fallback hides missing content. If something is missing, let it look missing.

Cost one: paths must be built in exactly one place

The classic way to get manual i18n wrong is to treat the language prefix as just another string you concatenate wherever you happen to need a URL.

I did exactly that. appHref(kind, slug) returned /tools/convert — no locale prefix. The real route was /[lang]/[kind]/[slug]/, so every app link on the homepage and both list pages was a 404. Worse, the Chinese site looked perfect, because something else on that path happened to supply a prefix. Only switching to English exposed it.

The fix was not “remember the prefix”. It was to close path building into a single function:

// src/server/meta.ts
export function href(locale: string, path = '/'): string {
  const normalized = path.startsWith('/') ? path : '/' + path;
  return '/' + locale + (normalized === '/' ? '/' : normalized);
}

No page or component concatenates a URL now. Adding a route means adding an xxxPaths to server/paths.ts.

Cost two: parity is discipline, not code

One file per language means “the translation never happened” is perfectly legal at build time: the build passes, the pages render, the English site is simply missing a few posts.

My actual state was three posts in Chinese against one in English and one in Japanese. Lists, archives, tags and RSS are all derived from that language’s post array, so English readers saw a one-post blog while the Chinese version looked complete.

There is no technical fix for this. Only a rule, written down. Mine lives in the spec I keep for agents:

Adding zh/x.md means adding en/x.md and ja/x.md too. Miss a language and the post is absent from that language’s list, archive, tags and RSS.

The rule comes with a per-language tag vocabulary, because tags are not aligned across languages. Skip that and you soon get three tags — performance, パフォーマンス, 性能 — each pointing at a single post.

The shape of it now

  • New language: extend LOCALES, LOCALE_META, the dictionary, and add a directory under content/blog/.
  • New post: three files. Nothing else.
  • Changed nav copy: one line per language in the dictionary.

The cost is that I have to remember the conventions, because nothing checks them for me. The payoff is that every behaviour lives in code I can read, with no fallback layer I do not control.

The value of an i18n setup is not that it supports languages. It is how honestly it behaves when one is missing.

← Back to all posts

Comments