Tags
The Astro rocket logo with a pink flame and the astro wordmark in white on a black card

Astro content collections instead of a CMS — MDX over Markdown, and where the foreign keys went

Why this trilingual Astro blog has no CMS: the directory layout images forced, MDX over Markdown, and a tag registry standing in for foreign keys.

Updated

On this page

Introduction

Before this blog had a single article in it, I priced a headless CMS for it. That is the normal answer for a site whose entire job is publishing text, and I dropped it anyway.

The reason was structural rather than a matter of taste. An article here is not a row. It is a directory holding three translations, an _images/ folder they share, and the figure components that carry the diagrams, and I wanted all of that to land in one reviewable commit. A CMS cannot offer that, because the content would live on the other side of an API.

So the repo has no database and no CMS: five runtime dependencies, a git history, and a build that fails when the content is wrong. This article walks through the decisions that fall out of that premise. The directory layout came from the images, not the languages. Every file became .mdx before there was anything to put in it. Tags got the identity a foreign key would have given them. And font stacks are selected per language rather than merged.

What a headless CMS would have cost

A CMS sells a web editor, a media library, a per-locale content model, workflow states, and a dashboard. Priced against this repo, each of those is either already free or actively in the way.

The filesystem is the schema and git is the revision history. An article, its images, its two translations, and the components that draw its diagrams are one commit and one diff. Split the text off into a hosted service and there is no longer any revision that means this article, complete: the repo has the images and the CMS has the prose, and the two versions drift on their own schedules.

One commit spans the article only when the prose is a fileTwo columns. On the left, a dashed commit boundary encloses all five parts of an article: the three locale files, the shared images folder, and the figures folder. On the right, the same commit boundary encloses only the images and figures folders, while the three locale files sit in a separate CMS box joined to it by an API link, so no single revision contains the article.Content as filesOne commiten.mdxja.mdxzh-tw.mdx_images/_figures/Prose, rasters, and figures move togetherOne revision is the whole article,so a translation reviews as a diffContent in a CMSCMS, reachable over an APIen.mdxja.mdxzh-tw.mdxAPIOne commit_images/_figures/Prose and assets version apart,so no revision is the whole article
The commit boundary is the same object in both columns. Only what falls inside it changes.

Reviewing a translation is the case that settles it. A translation is a diff against the text it came from, read side by side. In a CMS it is a form, and nothing in the form knows what the English said last week.

Validation is the other half. Because the content is files, it goes through the same check as the code:

package.json
"check": "astro check && pnpm run check:i18n && pnpm run thumbnails:check"

scripts/i18n-check.ts is 213 lines and reports what a schema alone cannot: an image on disk that no locale references, a frontmatter field that differs between locales when it is supposed to be identical, a figure present in the source and missing from a translation. These are integrity constraints across files, and they run in CI because the files are in the repo.

Even search survives the omission. pagefind --site dist indexes the emitted HTML after the build, so the site ships a static index instead of a query layer, and nothing has to be running for search to work.

What I gave up is real and narrow: there is no web editor, so no writing from a phone, and a non-technical contributor could not file an article. For a single-author blog whose author lives in a terminal, that is not a cost. The dashboard a CMS would have sold me is scripts/status.ts, at 76 lines.

Counting the corpus produced the voice rules

The consequence that reaches furthest is what files did to the writing rather than to the build. A corpus that is files can be counted, so the voice rules this blog is written to are measurements rather than opinions: an em-dash median of 1 per article across my published corpus against 17 in the first agent-written drafts, and bold spans at 1.1 to 2.9 per 100 words against 0.42 and 0.20. scripts/prose-check.ts turns the mechanical half of those into an exit code, so a draft that drifts fails a command rather than a mood.

A CMS would not have made any of this impossible, because exports exist. It would have put the corpus on one side of an export and the rule derived from it on the other, versioned separately and going stale independently. Being the only author is the other half: a multi-author corpus averages into a house style before you can measure a voice, and every count would need an author field before it meant anything.

One trap comes with it, and it is the obvious objection. Agent-written articles land in the same repo that the next measurement reads, so measuring the repo eventually measures the agent converging on its own habits. The numbers above are counted from articles I published before any of this tooling existed, and the agent-written drafts appear in them only as the counter-sample.

Images decide your directory layout

Every Astro i18n guide shows the same structure: one directory per language.

src/content/blog/
en/my-post.md
ja/my-post.md
zh-tw/my-post.md

This is fine until a post has a diagram. Then the diagram has to live somewhere, and both options are bad. Colocate it and you now maintain three copies of the same PNG. Hoist it to src/assets/blog/my-post/ and you have given up colocation: the image no longer travels with the article, and deleting the post leaves the file behind.

The fix is to stop treating language as the primary axis. The article is the thing; the language is an attribute of a file inside it.

src/content/blog/
astro-content-collections-no-database-mdx-tags-cjk-fonts/
en.mdx
ja.mdx <- absent means "not translated", which is a valid state
zh-tw.mdx
_images/
pipeline.png <- one copy, all three languages

The loader treats the folder as the slug and the filename as the locale:

src/content.config.ts
const blog = defineCollection({
loader: glob({ pattern: '**/[^_]*.{md,mdx}', base: './src/content/blog' }),
// ...
});

Entry IDs come out as astro-content-collections-no-database-mdx-tags-cjk-fonts/ja, which one helper splits back apart. Every locale file references ./_images/pipeline.png with the same string, and Astro emits a single optimised derivative for all three.

I asked for that claim to be checked rather than assumed. Building with one article produced 12 image derivatives across formats and widths. Adding a second article that reuses the same cover image produced 12, not 24. The asset pipeline deduplicates on content, so sharing is free.

The cost of this layout is that it is not what the documentation shows, so nothing you copy from a tutorial will fit without adjustment. That is the only cost, and it is worth paying the moment a single post has a single image.

Converting every file to MDX before writing anything

My articles were Markdown. Astro supports both, and the collection glob above accepts both, so nothing forced a decision. I converted everything to .mdx on day one of this repo anyway.

The conversion cost nothing, which is the first half of the argument. MDX is a superset of Markdown, so a prose-only file is byte-identical after the rename and behaves the same. There is no migration and no compatibility mode.

The second half is what .md cannot do. A diagram with text baked into a PNG can only ever be in one language, and it is the wrong one on two of the three sites. A component reads the current locale and draws the labels for it. That option only exists in a file the compiler will let you import into:

import Figure from '@/components/article/Figure.astro';
import Architecture from './_figures/Architecture.astro';
<Figure>
<Architecture />
<Fragment slot="caption">What the reader should take from it.</Fragment>
</Figure>

The bet took about thirty articles to pay off. Of the 35 locale files in the repo today, 20 import a figure component and 13 import nothing at all. The 13 are the reason the rule is uniform rather than case-by-case: there is no moment where an article has to be renamed because it grew a diagram, and no answer to give about which extension a new file gets.

Three syntax differences come with it, and all three fail the build with a line and a column, which makes them annoyances rather than hazards. Comments are {/* … */}, because <!-- … --> is invalid in MDX. A bare { or < in prose is parsed as JSX, so `{ foo: 1 }` needs backticks it should have had anyway. And translatable text goes in children, never in props: <Callout>text</Callout> survives translation into two more languages, while <Callout title="text" /> invites a translator to rewrite an attribute.

The same tag existed twice under two names

In my Japanese posts I tagged things 生成AI. In English, Generative AI. Both correct, and for a year it caused no visible problem.

The problem is identity, not translation. If the tag is whatever string sits in that language’s frontmatter, then /tags/生成AI and /tags/generative-ai are two unrelated pages listing overlapping articles, and neither of them is the tag. Add a third language and there are three.

This is the point where the missing database actually bites. A relational schema would have made the tag a row and the article-to-tag link a foreign key, and the constraint would have been the database’s job. With files, the constraint has to be built. A tag needs a language-neutral identity with a per-language label:

src/data/tags.ts
export const TAGS = {
'generative-ai': { en: 'Generative AI', ja: '生成AI', 'zh-tw': '生成式 AI' },
'i18n': { en: 'i18n', ja: '国際化', 'zh-tw': '國際化' },
} as const satisfies Record<string, Record<Locale, string>>;

Frontmatter carries the slug. /tags/generative-ai and /ja/tags/generative-ai are then the same article set under a localised heading.

The part I did not expect to care about is deriving the Zod enum from the registry:

src/content.config.ts
tags: z.array(z.enum(TAG_SLUGS)).default([]),

A typo’d tag is now a build error with the valid options listed, instead of a tag page that silently renders zero articles. That is referential integrity, enforced at build time by a type instead of at write time by a database. Free-string tags fail quietly, which is the worst way for a content bug to fail: nothing errors, the page just is not there.

The same shape solved cover images. A registry of shared thumbnails, referenced by key, means twenty posts can share one image, and the alt text is written once per language next to the image instead of twenty times in twenty frontmatters. Because the entry is already an optimised ImageMetadata, Open Graph images need no generator at all:

const og = await getImage({ src: cover.src, width: 1200, height: 630 });

I had budgeted for Satori. I didn’t need it.

One font stack is one language

This is the one I would not have found by reasoning about it.

Han characters are unified in Unicode: Japanese and Chinese share code points for characters that are drawn differently in each. 直, 骨, and 今 have distinct Japanese and Traditional Chinese forms at the same code point. Which shape you get depends entirely on the font, and no amount of correct text fixes a wrong one.

So the obvious single stack is wrong:

/* wrong — every CJK page renders with Japanese glyph shapes */
font-family: system-ui, 'Hiragino Sans', 'Noto Sans TC', sans-serif;

Hiragino Sans covers the characters a Traditional Chinese page needs, so the browser never falls through to Noto Sans TC. The page renders. Nothing is missing. The glyphs are just subtly, consistently wrong: invisible to me, immediately visible to a native reader.

The fix is to select per language rather than merging:

src/styles/global.css
html[lang^='ja'] {
--font-sans: system-ui, 'Hiragino Sans', 'Noto Sans JP', sans-serif;
}
html[lang^='zh'] {
--font-sans: system-ui, 'PingFang TC', 'Noto Sans TC', sans-serif;
}

This is also the strongest argument for the MDX decision above, because the same trap applies to generated diagrams and there it is worse. My SVG-to-PNG pipeline pins Hiragino Sans, because bare sans-serif renders tofu under cairosvg. Point that at Traditional Chinese labels and you get plausible, wrong-looking characters baked into a PNG, with no fallback and no warning. Tofu would honestly have been better, because tofu is obvious. A figure component has no such failure mode, because it inherits whichever stack the page selected.

So the rule I wrote into the diagram skill is to prefer language-neutral labels (POST /articlesvalidatequeue needs no translation at all), and to render rather than bake whenever labels are unavoidable.

Translations are derived, not stored

Three languages ship. Two weeks later a wrong sentence in the English gets fixed, and the other two versions are now wrong with nothing to say so. No error, no warning, no failing build.

My first answer was detection. Each translated file recorded a hash of the source as it stood when the translation was made:

translation: 'machine'
sourceHash: 'a3f8c21b09e4d7f2'

Recompute at check time, compare, report drift. I built it. Then I noticed the hashing was not the hard part. Deciding what counts as a change was, and every question about it was a judgement call with no right answer:

  • Does reflowing a paragraph count?
  • Does editing a comment inside a code block count, given the code is identical in all three languages but a changed comment is prose the reader sees?
  • Does renaming an image file count?

Worse, the rule is a ratchet. Fifty articles hashed under one definition, then you refine the definition, and all fifty report stale at once. Either you re-translate everything or you re-stamp the hashes blindly and destroy the signal you built the thing for.

Don’t maintain translations beside the source. Derive them from it at publish time. Publishing regenerates every target locale from the current source and then flips the status, so text can only reach the site through that path and a live translation always came from the live source. There is no window in which they can disagree, so there is nothing to detect. One rule makes it airtight: re-publishing after a source edit refreshes every locale already live, not just the one you named.

It costs something. Translations are regenerated rather than patched, so regeneration has to preserve hand-corrections whose source has not changed. That is a real trade, and a much smaller one than a bookkeeping system I would learn to distrust. The lesson I would keep: when a check is hard to tune, ask whether you can close the gap instead of monitoring it.

It is also the same instinct as the missing database. Drift was derived state I was one commit away from storing.

Two axes, not one

A single status: draft | published per article cannot express the state of a trilingual post, because the states are independent: English live, Japanese translated and sitting unpublished, Chinese not written. So there are two fields, and they answer different questions:

  • status: draft / ready / published, per locale, gates publication
  • translation: source / translated, records which locale the article was written in

translation used to carry a third value, machine, kept distinct from reviewed so the site could warn readers about unreviewed output. I dropped it, because nothing can verify it. A hand-edited file is indistinguishable from machine output, so the field was a self-reported claim that no step updated, and across nineteen articles it was never once set.

Which also settles whether a post must exist in all three languages before any of it ships. It doesn’t. A missing translation is a permanent valid state, so astro.config.mjs sets no i18n fallback at all: missing locales 404 rather than silently serving another language, and hreflang advertises only what actually built. Pointing search engines at a language you have not written is worse than admitting you have not written it.

Summary

None of this is exotic, and none of it is Astro-specific beyond the loader config. What the decisions share is a premise: there is no database, so every constraint a database would have enforced has to be built somewhere else, and the cheapest place is the type system and the build.

If you are starting a multilingual blog from files, the order that mattered:

  1. Decide the content layout from your images, not your languages.
  2. Convert to .mdx before you need it. It costs nothing and it is the only way a diagram gets translated.
  3. Give tags a language-neutral identity, and make an invalid one a build error.
  4. Select fonts per language. Never merge CJK stacks.
  5. Derive translations at publish time rather than building something to watch them drift.

Share this article