Skip to content
← posts

Publishing a bilingual blog from a static export

Every post on this blog exists twice — once in English, once in Indonesian — and the site is a static export, so both versions are prerendered at build time and there's no server left running to fix anything afterwards. That constraint turns out to decide the whole design. Here's the shape it forced, and the one place the type system quietly stops helping.

A post is one registry entry and two bodies

The metadata lives in TypeScript, in a single ordered array:

export type PostMeta = {
  slug: string
  date: string // ISO 8601
  title: Record<Locale, string>
  description: Record<Locale, string>
}

The bodies live on disk as src/content/<locale>/<slug>.mdx — plain Markdown, with JSX available when a post needs a real component. The route prerenders one static page per locale × slug, pulling the right body in by name:

export const dynamicParams = false

const { default: Body } = await import(`@/content/${lang}/${slug}.mdx`)

Record<Locale, string> is doing more work than it looks. Add a locale to the project and every post stops compiling until it has a title and a description in that language. Forget the Indonesian title on a new post and tsc names the exact line. Translation of the metadata is a compile-time obligation.

The bodies have no such guarantee

Nothing in that type mentions the files. The body arrives through a template-literal dynamic import, which the bundler resolves by generating a module context over src/content — it matches paths at build time, not types at check time. So the registry can promise a post the filesystem doesn't have.

I tried it: add an entry with no .mdx anywhere, then build.

Running TypeScript ...
Finished TypeScript in 4.7s ...
Generating static pages using 11 workers (0/18) ...
Error occurred prerendering page "/en/posts/probe-missing-body".
Error: Cannot find module '@/content/en/probe-missing-body.mdx'
    at moduleContext (...[turbopack]_runtime.js:332:19)
Export encountered an error, exiting the build.

TypeScript passes, cleanly, and reports as much. Four seconds later prerendering dies on a module that was never there.

Failing at build is the feature

That looks like a gap, and for about a minute I treated it as one. It isn't. The static export is what makes it safe: because dynamicParams is false, every locale × slug pair is enumerated from the registry and prerendered, so a body that doesn't exist is a build that doesn't finish. The broken page cannot reach production, because production is a folder of HTML files and that file was never written.

Run the same code under SSR and the same mistake ships. generateStaticParams becomes a hint rather than an inventory, the import resolves lazily on first request, and the missing translation surfaces as a 500 for whoever happens to open the Indonesian link first — probably not me, and probably not soon.

The bug is identical. The rendering strategy decides whether it's caught by a build or by a reader.

So the two halves guard each other, at different times:

MistakeCaught byWhen
Missing per-locale title / descriptionTypeScripttsc, in-editor
Missing per-locale bodyPrerendernext build
Slug not in the registryRouternever renders

The last row is the same principle once more: dynamicParams = false means an unknown slug has no page rather than a lazily-built one.

The loop

Adding a post is a .mdx under each src/content/<locale>/, an entry in src/lib/posts.ts, and next build. If either half is missing, the build says so. That's the whole publishing pipeline, and it's the reason I never have to remember whether I translated something — I only have to remember to build.

The cost is honest and small: a bilingual blog is two blogs, and no type can tell me whether the Indonesian body actually says what the English one says. It only promises the file is there. The rest is my problem.