# 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.

- Source: https://oharu121.com/blog/astro-content-collections-no-database-mdx-tags-cjk-fonts/
- Published: 2026-08-04T00:50:42+09:00
- Updated: 2026-08-10T17:44:46+09:00
- Tags: Astro, i18n, Markdown

---
## 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.

*Figure — CommitBoundary: 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:

```json title="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.

```text
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.**

```text
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:

```ts title="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:

```mdx
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:**

```ts title="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:

```ts title="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:

```ts
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:

```css
/* 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:

```css title="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 /articles` → `validate` → `queue` 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:

```yaml
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.
