Astro’s i18n story looks deceptively simple. Add locales and defaultLocale to your config, put pages in src/pages/zh/, done. That part really is that easy.

The problems start afterwards. Here are the four that cost me the most time building this site.

1. The official language switcher sends everyone to the homepage

The i18n recipe in Astro’s docs ships a LanguagePicker that does roughly this:

<a href={`/${lang}/`}>...</a>

Which means if a reader is halfway through /services and clicks “中文”, they land on the Chinese homepage and lose their place. On a five-page site that is annoying. On a site with forty articles it is a bounce.

The fix is to strip the current locale prefix, keep the rest of the path, and re-prefix it:

export function switchLocalePath(targetLang: Locale, currentPathname: string): string {
  return localizedPath(targetLang, stripLocale(currentPathname));
}

Which only works if both language versions use the same slug. So decide early: en/my-post.md pairs with zh/my-post.md. As soon as you start translating slugs (zh/wo-de-wen-zhang.md), you need an explicit mapping table, and that table is one more thing that can drift.

2. Astro 5 removed slug from content entries

If you learned content collections before Astro 5, you expect entry.slug. With the content layer and the glob() loader you get entry.id instead, and it is a path relative to your loader’s base — including the language folder.

So src/content/blog/en/my-post.md produces id: "en/my-post". You have to split it yourself:

const [lang, ...rest] = entry.id.split('/');
const slug = rest.join('/');

Small thing, but every tutorial published before 2025 shows the old API.

3. compressHTML changed its default in Astro 7

This one is genuinely sneaky. Astro 7 defaults compressHTML to 'jsx', which strips whitespace using JSX rules. That means:

<span>hello</span>
<em>world</em>

renders as helloworld, not hello world.

For a monolingual site with mostly block-level markup you may never notice. For a site mixing English prose (where the space between inline elements is a real word boundary) with Chinese text (where it usually is not), you get intermittent missing spaces that look like typos.

Set compressHTML: true if you want the old HTML-aware behaviour.

4. hreflang is three lines you will forget

Every page in both languages needs the full set, including a self-reference:

<link rel="alternate" hreflang="en" href="https://example.com/services" />
<link rel="alternate" hreflang="zh-Hans" href="https://example.com/zh/services" />
<link rel="alternate" hreflang="x-default" href="https://example.com/services" />

Two details that are easy to get wrong:

  • Use zh-Hans, not zh. zh is ambiguous between simplified and traditional; zh-Hans is what Google recommends for simplified Chinese content.
  • Do not auto-redirect based on Accept-Language or IP. It breaks the hreflang cluster and, at worst, gets read as cloaking. Put a manual link in the header instead.

What I would do differently

Decide the slug-matching rule before writing a single post. Everything else — the switcher, the hreflang tags, the sitemap, the canonical URLs — falls out of that one decision for free.