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:
| Mistake | Caught by | When |
|---|---|---|
| Missing per-locale title / description | TypeScript | tsc, in-editor |
| Missing per-locale body | Prerender | next build |
| Slug not in the registry | Router | never 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.