Our main pages — the company profile, our business, and so on — have long supported Japanese, English, and Simplified Chinese. The blog was the exception: it stayed Japanese only, and the English and Chinese pages simply linked straight through to the Japanese articles. We’ve now added English and Simplified Chinese versions across every existing article, bringing the blog to full three-language support. This article covers the design, the implementation, and a couple of constraints we ran into along the way.
Design — matching translations by filename
The blog runs on an Astro content collection: each file under src/content/blog/*.md is one article, and the filename becomes the URL directly. The first thing we had to decide when adding translations was how to match a Japanese article to its translated counterpart.
Astro lets you define multiple content collections in src/content.config.ts. On top of the existing blog collection (Japanese), we added blogEn and blogZh. All three use the same glob() loader, differing only in which directory they point at: src/content/blog, src/content/blog-en, and src/content/blog-zh.
The official documentation describes how the glob() loader behaves:
When using the glob() loader with Markdown, MDX, Markdoc, JSON, or TOML files, every content entry id is automatically generated in an URL-friendly format based on the content filename.
When using the glob() loader with Markdown, MDX, Markdoc, JSON, or TOML files, every content entry's id is generated automatically, in a URL-friendly format, based on its filename.
In other words, the filename becomes the entry’s id within that collection. That gave us the design: if all three collections use the same filename (YYYY-MM-DD-slug.md), matching “the same article” across languages reduces to a filename match. Since /blog/<slug>, /en/blog/<slug>, and /zh/blog/<slug> all point at the same <slug>, the previous/next-article navigation, the language switcher, and hreflang tags could all be built from a simple set operation — checking whether an entry with the same id exists in another collection.
We also chose not to give translated articles their own thumbnail field. They always reference the Japanese version’s thumbnail by matching <slug>, so there’s no image duplication and nothing to keep in sync when a thumbnail changes.
Routing — getStaticPaths is required on every page
Astro defaults to static site output (SSG). For dynamic routes, the official documentation states:
Because all routes must be determined at build time, a dynamic route must export a getStaticPaths() that returns an array of objects with a params property.
Because all routes must be determined at build time, a dynamic route must export a getStaticPaths() function that returns an array of objects, each containing a params property.
This getStaticPaths() has to live in the page file itself (src/pages/**/*.astro) — it can’t be delegated to another Astro component. Our blog has three kinds of pages — the article list, the article detail page, and the per-tag list — and multiplying that by three languages means nine separate page files, each needing its own getStaticPaths().
Implementing all nine independently would have meant triplicating (or worse) the markup, CSS, and JavaScript. Instead, we kept the page files narrow — fetch the right collection, resolve the URLs via getStaticPaths(), pass the data along — and moved the actual markup, copy, and scripts into shared components: BlogPostPage.astro, BlogListPage.astro, and BlogTagPage.astro. This extends a pattern we already used for our static pages (company profile, business, etc.) — a component that takes a locale prop, wrapped by a thin page file per language — to the dynamic routes as well.
src/pages/blog/[slug].astro // getStaticPaths (blog collection) + <BlogPostPage locale="ja" .../>
src/pages/en/blog/[slug].astro // getStaticPaths (blogEn collection) + <BlogPostPage locale="en" .../>
src/pages/zh/blog/[slug].astro // getStaticPaths (blogZh collection) + <BlogPostPage locale="zh" .../>This structure also simplified the language switcher and hreflang tags: each page’s getStaticPaths() checks whether the same slug exists in the other two collections and passes the resulting list of available locales down to the shared layout as a translatedLocales prop.
A constraint we hit — the footnote heading label is a single, site-wide setting
Articles on this site cite sources either with a manual ### 参考 heading and list, or with Markdown footnote syntax ([^1]). With footnote syntax, Astro’s markdown processor auto-generates a heading at the end of the article — and on this site, that heading’s label is hard-coded to “参考文献” in astro.config.mjs.
markdown: {
processor: satteri({
features: {
gfm: {
footnotes: {
label: '参考文献',
backLabel: '本文の参照箇所{reference}に戻る',
},
},
},
}),
},This markdown-processor configuration is a single, site-wide instance — there’s no mechanism to switch it per article language. That means an English article using footnote syntax would still get a heading reading “参考文献” instead of anything in English.
Our workaround: translated articles don’t use footnote syntax at all, even for articles whose Japanese original does. Instead, translations convert footnotes into a manual heading-plus-list (### References in English, ### 参考资料 in Chinese). Since the site already allowed either “### 参考” or “footnote syntax” as valid ways to cite sources, this just meant standardizing translations on the former.
Summary
- Astro lets you define multiple content collections. Since the
glob()loader generates each entry’s id from its filename, using the same filename across three collections (blog,blogEn,blogZh) lets a simple filename match express “the same article” across languages - In Astro’s static output,
getStaticPaths()for a dynamic route must live in that page file and can’t be delegated. So we kept page files focused on data-fetching and moved markup, copy, and scripts into shared components that take alocaleprop - Markdown-processor settings (like the footnote heading label) are a single, site-wide instance. We worked around this by not using footnote syntax (
[^n]) in translated articles, converting them to a manual heading-plus-list instead - Thumbnails aren’t duplicated — translated articles always reference the Japanese version’s image
References
- Content collections — Astro Docs (accessed July 25, 2026)
- Routing — Astro Docs (accessed July 25, 2026)