Skip to main content
Zhimalab
中文

Astro Static Site Internationalization Best Practices: A Complete Rollout from Single-language to Chinese-English

2026-08-19 · 42 min

This site (Zhimalab, zhimalab) is a pure static site built on Astro 7: blog + 50+ frontend tools + interactive tutorials + AI lab + 15 Phaser games. Since August 2026, while keeping the Chinese site (/) completely unchanged, we added an English version (/en/*) for the whole site. This article organizes the complete solution, layered model and pitfall checklist of this round of internationalization, for reference by anyone doing multilingual Astro static sites later.

One-sentence conclusion: internationalizing an Astro static site is essentially three things — unify URL routing, split copy into layered dictionaries, and wrap up SEO with hreflang.

Current status (completion as of 2026-08)

Scope Count Status
Home / section pages dev / kids / nav / ai(5) / blog(2) / search / admin ✅ bilingual shell
Tool pages 56 ✅ bilingual shell + component UI mostly translated
Tutorial pages 26 ✅ bilingual shell (course data still Chinese)
Game pages 15 ✅ bilingual shell (in-game copy translated for 4)
Blog 23 posts 🟡 /en/blog/* shell ready, content falls back to Chinese
Search index 1 🟡 still single-language zh

1. URL and routing: [...lang] + manual routing

1.1 Decision: path prefix, no subdomain

Use the /en/* prefix; Chinese stays at the root path /:

  • //en
  • /blog/xxx/en/blog/xxx

Reason: a pure static site has no server-side language negotiation. A path prefix is the friendliest for CDN, caching and crawlers; a subdomain splits the site’s authority, and a ?lang= query parameter isn’t great for sharing or indexing.

1.2 astro.config.mjs configuration

export default defineConfig({
  site: 'https://zhimalab.tech',
  i18n: {
    locales: ['zh', 'en'],
    defaultLocale: 'zh',
    routing: 'manual', // language routing handled by [...lang] itself
  },
  integrations: [
    react({ include: ['**/*.jsx', '**/*.tsx'] }),
    mdx(),
    sitemap({
      i18n: { defaultLocale: 'zh', locales: { zh: 'zh-CN', en: 'en' } },
    }),
  ],
});

Key point: routing: 'manual' means not using Astro’s built-in Accept-Language redirects and URL rewriting; language is entirely decided by the routes themselves. The cost is that you must provide src/middleware.ts (otherwise the build errors). The middleware does one thing — write the current language into locals:

export const onRequest = defineMiddleware((context, next) => {
  const { pathname } = new URL(context.url);
  context.locals.lang = pathname.startsWith('/en') ? 'en' : 'zh';
  return next();
});

1.3 [...lang] rest route + getStaticPaths

All pages that need bilingual support live under src/pages/[...lang]/, generating both languages via getStaticPaths:

export function getStaticPaths() {
  return [
    { params: { lang: undefined }, props: { lang: 'zh' } }, // matches root path /
    { params: { lang: 'en' }, props: { lang: 'en' } },      // matches /en
  ];
}

Note the lang: undefined trick: [...lang] is a rest param, and undefined lets it match the top-level path — the Chinese home page is / instead of /zh, so SEO authority isn’t split.

1.4 Language path conversion utility src/i18n/paths.ts

The language switcher in the nav needs “current path ↔ target-language path” conversion; extract it as a pure function to avoid scattering it around:

export function langPrefix(lang: Lang): string {
  return lang === 'en' ? '/en' : '';
}

export function toLangPath(pathname: string, target: Lang, from: Lang): string {
  if (target === from) return pathname;
  // home page special case: / ↔ /en
  // other paths just add/remove the prefix, e.g. /blog/xxx → /en/blog/xxx
}

LangSwitcher.astro hangs on the nav and calls toLangPath(Astro.url.pathname, target, lang) to get the target-language URL.

2. Copy dictionaries: split files by layer, not one giant JSON

This is the most important organizational principle of the whole solution. Split the copy into four layers by “scope”:

Layer Location Content Consumer
Global shell src/i18n/ui.ts nav / footer / breadcrumb / theme toggle / search placeholder AppLayout.astro, LangSwitcher
Page-level src/i18n/home.ts, tools-en.mjs, tutorial-en.mjs, games-en.mjs per-section-page title / description / keywords / h1 each [...lang]/*.astro
Component-level TEXT = { zh, en } inside components React island buttons / hints / labels React components (via lang prop)
In-game src/games/*/i18n.ts all copy inside Phaser scenes game runtime

2.1 Global shell: typed dict

ui.ts uses an interface to force the keys of both languages to align exactly; a missing key fails compilation directly:

export interface UiLabels {
  siteName: string;
  skipLink: string;
  home: string;
  blog: string;
  themeToggle: string;
  searchPlaceholder: string;
  switchToEn: string;
  switchToZh: string;
}

export const uiDict: Record<Lang, UiLabels> = {
  zh: { siteName: '芝麻园地', skipLink: '跳到主内容', /* ... */ },
  en: { siteName: 'Zhimalab', skipLink: 'Skip to main content', /* ... */ },
};

AppLayout.astro receives the lang prop; nav / footer / breadcrumb all go through uiDict[lang], with no bare Chinese in the template.

2.2 Component-level: TEXT = { zh, en } + lang prop

React islands can’t access Astro’s Astro.currentLocale, so the convention is that each component carries its own dictionary, with lang passed down uniformly via ThemeProviderWrapper:

const TEXT = {
  zh: { copy: '复制', copied: '已复制' },
  en: { copy: 'Copy', copied: 'Copied' },
};

function CopyButton({ lang = 'zh' }) {
  const t = TEXT[lang];
  return <button>{t.copy}</button>;
}

Principle: the dictionary follows the component, not a global free-for-all. This way components are reusable across pages/projects, the language is injected by the container, and the component itself stays unaware.

2.3 In-game: i18n.ts dictionary + bilingual data fields

Phaser games are runtime Canvas rendering and can’t use Astro template interpolation, so each game carries its own i18n.ts. Taking cloud-collector as an example, it uses the “zh as the base, en key-aligned by force” style:

const zh = {
  menuTitle: '云朵收收乐',
  modeSingle: '单人闯关',
  helpLine1: '1. 拖动同色云朵到一起,它们会合并成更大的云团',
  score: '分数: {n}',
  // ...about 60 keys
} as const;

const en: Record<keyof typeof zh, string> = {
  menuTitle: 'Cloud Collector',
  modeSingle: 'Single Player',
  helpLine1: '1. Drag same-color clouds together to merge them into a bigger cloud',
  score: 'Score: {n}',
  // ...
};

en: Record<keyof typeof zh, string> guarantees no English key is missing; copy with {n} placeholders is rendered via a template replacement function.

Static data inside games (levels, themes, weather knowledge) goes in bilingual fields, rather than being stuffed into the dictionary — data is content, copy is UI, and the two are maintained separately:

// data/levels.ts
{
  name: '云朵初现',
  nameEn: 'First Clouds',
  description: '拖动同色云朵合并',
  descriptionEn: 'Drag same-color clouds to merge',
}

Combined with helper functions like localizedName(), the field is picked by current language at render time:

export const localizedName = (d: { name: string; nameEn: string }, lang: Lang) =>
  lang === 'en' ? d.nameEn : d.name;

3. Three “i18n granularities”: shell / data / content

Astro site internationalization must be handled in three granularities, not one-size-fits-all:

3.1 Page shell (structural copy)

Nav, footer, breadcrumb, meta tags, buttons — these are “page frames”, solved with dictionaries + lang prop. AppLayout is responsible for:

  • outputting <html lang> per language
  • outputting og:locale per language (zh_CN / en_US)
  • auto-outputting the hreflang trio on en pages (en-US / zh-CN / x-default)

3.2 Structured data (lists / cards)

For structured data like tool names, game names and course titles, add bilingual fields to each record (name / nameEn), selected by lang on list pages. Benefit: lists, cards and JSON-LD share the same data, no duplicated translation.

3.3 Content collections (blog posts)

Blogs use Astro Content Collections. content.config.ts declares the posts collection supporting bilingual directories:

const posts = defineCollection({
  loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/posts' }),
  locales: ['zh', 'en'], // English posts go in posts/en/
  schema: z.object({
    title: z.string(),
    description: z.string().optional(),
    date: z.union([z.string(), z.date()]),
    tags: z.array(z.string()).default([]),
    draft: z.boolean().default(false),
  }),
});

The detail page [slug].astro rendering strategy: the English version reads posts/en/<slug> first; if no translation exists, it falls back to the Chinese original with a “中文” tag:

if (lang === 'en') {
  try {
    entry = await getEntry('posts', 'en', slug);
  } catch {
    entry = undefined;
  }
  if (!entry) {
    entry = await getEntry('posts', slug); // fall back to Chinese
    isFallback = true; // render the ZhHint "中文" badge
  }
}

ZhHint.astro is a small badge component that marks “this is still Chinese” on English pages, so English readers aren’t misled. Reading time is also computed per language: estimateReadingTime(entry.body, lang) estimates at 350 characters/minute, outputting “X 分钟” in Chinese and “X min” in English.

Conclusion: using “fallback + badge” instead of “forced paired translation” for content collections allows gradual rollout — translate popular posts first, cold posts fall back, and reader experience isn’t harmed.

4. SEO wrap-up: canonical + hreflang + sitemap

SEO is the most overlooked part of internationalization, and getting it wrong directly causes duplicate-content issues.

4.1 Every page outputs canonical (to itself) + hreflang (bilingual cross-reference)

<link rel="alternate" hreflang="zh-CN" href="https://zhimalab.tech/blog/xxx/" />
<link rel="alternate" hreflang="en" href="https://zhimalab.tech/en/blog/xxx/" />
<link rel="alternate" hreflang="x-default" href="https://zhimalab.tech/blog/xxx/" />

zh pages pass the hreflang prop explicitly to AppLayout; en pages have AppLayout auto-generate the alternate pointing to the Chinese version (toLangPath path conversion).

4.2 sitemap with language mapping

The @astrojs/sitemap i18n config maps language codes to real locales (zhzh-CN), and the generated sitemap automatically includes alternate links for every URL:

sitemap({
  i18n: { defaultLocale: 'zh', locales: { zh: 'zh-CN', en: 'en' } },
})

5. Runtime language in games: URL → localStorage → browser language

Games are runtime scenes; the language may need to switch temporarily inside the game (without affecting the page shell). We use a two-layer language model:

  1. Site locale (decided by URL): responsible for SEO, sharing, and the page shell
  2. App-level override (in-game): inherits the URL locale by default, switchable at runtime, stored in localStorage

Detection order (the pattern used by completed games like cloud-collector):

export function detectLang(): Lang {
  // 1. URL query param ?lang=en (shareable link direct pass)
  const q = new URLSearchParams(location.search).get('lang');
  if (q === 'zh' || q === 'en') return q;
  // 2. URL path /en/ prefix (authoritative language for SEO / sharing)
  if (location.pathname.startsWith('/en')) return 'en';
  // 3. localStorage: memory of in-game manual switching
  const saved = localStorage.getItem('cloudCollector.lang');
  if (saved === 'zh' || saved === 'en') return saved;
  // 4. browser language fallback
  return navigator.language.toLowerCase().startsWith('zh') ? 'zh' : 'en';
}

6. Pitfall checklist (anti-patterns)

Real pitfalls from this round:

Pitfall Description Correct approach
Hardcoded Chinese in JS games like bubble-words / mingdrum write Chinese via textContent = '开始' at runtime, can’t switch build an i18n.ts dictionary, read per detectLang() at runtime
Dictionaries in src/pages/ Astro treats them as routes and generates pages put them uniformly in src/i18n/ or beside components
Undeclared fields in schema custom frontmatter fields like lang: fail validation only use fields declared in content.config.ts
Ignoring the search index generate-search-index.mjs derives slugs from relative paths, so posts/en/*.md generates wrong /blog/en/<slug> URLs, and the index content stays single-language add a lang field to the index and filter by language (D6, to be implemented)
Cross-layer state interference driving the game loop with React state / holding a Phaser instance in React keep project boundaries: Phaser writes, React reads, one-way data flow
Confusing the getEntry signature the three-arg getEntry('posts', 'en', slug) vs the old two-arg signature is easy to confuse confirm whether this API form survives when upgrading Astro

Two more known boundaries (deliberate decisions, not pitfalls):

  • WebUI 88 pages: screenshot/demo pages, only the shell is translated, not the body
  • poem-mage (ancient-poetry game): content depends heavily on Chinese, kept single-language

7. Reusable migration scripts

This internationalization wasn’t hand-editing 100+ files — we wrote three idempotent migration scripts that scan old pages and auto-generate the [...lang] versions:

Script Purpose
scripts/migrate-tools-i18n.mjs 56 tool pages → [...lang]/tools/*.astro
scripts/migrate-tutorials-i18n.mjs 26 tutorial pages → [...lang]/tutorial/*.astro
scripts/migrate-games-i18n.mjs 15 game pages → [...lang]/games/*.astro (including fullscreen-script define:vars injection)

8. Summary

The complete chain for internationalizing an Astro static site:

astro.config i18n config


[...lang] rest route + getStaticPaths   ← unify URL routing

        ├── page shell → src/i18n/ui.ts dictionary (consumed by AppLayout)
        ├── page-level   → module dictionaries like home.ts / tools-en.mjs
        ├── component-level   → TEXT = { zh, en } + lang prop
        ├── in-game   → i18n.ts dictionary + bilingual data fields + detectLang()
        └── content collections → posts/en/*.md falls back to Chinese + ZhHint


canonical + hreflang + sitemap(i18n) + og:locale   ← SEO wrap-up

Remember this solution in three sentences:

  1. Use [...lang] + routing: 'manual' for routing, Chinese stays at the root path, authority isn’t split
  2. Split copy into layered dictionaries: shell ui.ts, page modules, component TEXT, game i18n.ts; typed dicts guarantee key alignment
  3. Wrap up SEO with hreflang, translate content gradually with “fallback + badge” — get it working first, then scale up