Starlight, or not
The obvious move for a multilingual docs site is Starlight. I seriously considered it. The trade-off came down to flexibility: I wanted one shell that served a marketing landing page, a product page, product docs, and a blog - across multiple products, with a locale prefix - and I wanted fallback that was content-level rather than a redirect.
Starlight is opinionated about docs; I had a site that was mostly not docs. So I built the i18n myself. It is less than 200 lines of helpers.
Locale in front
The URL decision: /zh/packscope/docs/ or /packscope/docs/zh/? I went with the locale in front.
en is the default locale and gets no prefix; every other locale lives under /<locale>/...:
https://open.awareride.com/packscope/docs/getting-started- Englishhttps://open.awareride.com/zh/packscope/docs/getting-started- Chinese
The whole site mirrors that: /zh/, /zh/packscope/, /zh/posts/. One rule, applied everywhere.
Fallback that is not a redirect
The important part. A missing translation should never 404. But a blanket redirect to English is wrong too - the reader asked for Chinese, and silently dumping them into an English page is disorienting.
So fallback is per-page and content-level. A zh URL always exists. If there is a Chinese page
for it, it renders the Chinese body in a Chinese shell (<html lang="zh">, Chinese nav, Chinese
breadcrumbs). If there is not, that same URL renders the English body - still inside the Chinese
shell, with a visible notice:
此页暂无中文翻译,以下显示英文原文。
You can ship English first and translate incrementally. The site never breaks; it just shows English
with a notice where Chinese is missing. Post cards on /zh/posts/ even carry an EN badge on
fallback entries so readers know before they click.
No per-locale boilerplate
The trap with “one collection per locale” is that every new language means a new defineCollection,
a new set of route files, and a new place to forget something. I refused to write
src/lib/doc-route-zh.ts.
Instead, collections and routes are generated from a single source of truth:
// i18n.ts - the only place locales live
export const locales = ['en', 'zh'] as const;
export const defaultLocale = 'en';
// content.config.ts - one product × one locale = one collection, auto-generated
for (const product of products) {
for (const locale of locales) {
out[`${product}Docs${cap(locale)}`] = defineCollection({
loader: glob({ pattern: '**/*.md', base: `./src/content/docs/${product}/${locale}` }),
schema: docSchema,
});
}
}
Adding a language is appending to locales and to a t object of UI strings. The collection
loop, the path helpers, the fallback logic, and the routes all stay generic over locales. There
are no *-zh.ts files to forget.
The small things that matter
- The
indexdoc is special. It is served by a dedicatedindex.astroroute at the collection base (/packscope/docs), never a duplicate/packscope/docs/index/. The catch-all excludes it. - The sidebar matches the body, not the shell. When a page is a fallback (English body in a Chinese shell), the nav is built from the English collection - so the links you see match the language of the text you are reading.
zh, notzh-CN. Simpler. Regional variants can come later if they ever matter.
The result is a site that is fully localized where translations exist, gracefully English everywhere else, and maintained by editing exactly one array to add a language.
Part of the series on building open.awareride.com. Previous: Deploying to GitHub Pages and Cloudflare Pages. Next: a content hub synced via pull requests.