Markdown Blog (multilingual, tagged)
A blog where the content is files, not rows. The whole design turns on one idea:
a post's identity is its translationKey, not its slug. Slugs are localized
for SEO and differ per language; the key is what makes three files one article,
and it is what powers hreflang, the language switcher, related posts, and
cross-locale redirects. Get that one field right and everything else is lookups.
Written by the engineer who has shipped this module. The earlier implementation
it was audited against was a multi-locale, statically generated marketing blog
with its sitemap and SEO surface. The facts and rules below are what the
content-layer suite and the build verify; references/provenance.md has the
record.
When to use
- Content authored as markdown in the repo, deployed with the app
- More than one language, with different slugs per language
- Tag pages, related posts, feeds, or hreflang are in scope
- You want the whole thing statically generated at build time
When NOT to use
| Instead of this | Use |
|---|---|
| A hosted CMS is the source of truth | that CMS's SDK; keep only references/content-model.md |
| A docs site / help center (categories, sidebar, search) | a help-center skill — different navigation model |
| A single-locale blog of <10 posts | plain fs + gray-matter inline; this is overhead |
| Rendering user-submitted markdown | a sanitizing renderer; see the XSS note in references/rendering.md |
Architecture
content/blog/<locale>/<localized-slug>.md # frontmatter + body
|
v
parseFrontmatter --YAML, falls back to a line parser on malformed quotes
|
v
getAllPosts(locale) <-- memoized per locale in production
|
+--> translationKey ----> alternates / hreflang / language switch
+--> tags -> tag SLUG ---> tag pages (slug is the identity, not the label)
+--> related (EN slugs) -> related posts, with an EN fallback
|
v
/blog /blog/[slug] /blog/tag/[slug] sitemap.xml feed.xml
Critical facts
- Relations are authored once, in the default locale, as default-locale
slugs. A translated file may omit
relatedentirely and still get the right related posts, resolved throughtranslationKey, so every translation renders the same related section. The fallback test holds it. - A tag's identity is its slug, not its label.
"AI Agents"and"AI agents"slug to the same URL. Group by slug and match posts by slug, so every spelling variant's posts appear on the one tag page. Two tests hold it. gray-matterthrows on a double-quoted YAML scalar containing raw quote characters — typographic quotes in a translated excerpt do it. Without a fallback parser the build stops on that one file; the line-by-line fallback keeps it loading. Verified against the library and covered by a test.gray-mattercaches a failed parse as an empty result. It writes its cache entry before parsing, so the second and every later parse of a file that threw returns{}— no error, no frontmatter. Always callmatter(contents, {}); any options object opts out of the cache.- Loading is O(posts x locales) per page unless you memoize. Memoize per
locale so each content file is read once per build; resolving alternates in
generateMetadatais otherwise a 71x file-read amplification, measured on the earlier implementation. getPostSlugsmust filter.md. The filter keeps a stray.DS_Storefrom becoming a.DS_Store.mdread and anENOENT. A test holds it.- Tag pages have no cross-locale identity. Tags are per-locale free text, so the language switcher must fall back to the blog index on a tag URL rather than build a URL that 404s.
Hard rules
Never key cross-locale lookups on the slug. Slugs are localized on purpose.
translationKeyis the join column; a slug match across locales is a coincidence, not a relation.
Never let
getPostBySlugdecide draft visibility implicitly. Filter drafts in the loader;dynamicParams = falsehides them only as a side effect and stops doing so the moment the flag changes. The draft test holds the filter.
Never render a "5 min read" constant. Compute it from the body; it costs one
split, and the reading-time test holds it.
Never derive a tag page's URL from the label at read time and the label from the URL at render time without a collision check. That round trip is only lossless while every label slugs uniquely.
Quick start
- Set the locale contract and the frontmatter schema — content-model.md
- Build the loader (memoized,
.md-filtered, fallback parser) — content-loader.md - Wire locales, alternates and cross-locale redirects — i18n-and-routing.md
- Add tag grouping and tag pages — tags.md
- Build the three routes, metadata and JSON-LD — pages-and-seo.md
- Render bodies and covers — rendering.md
- Add the validation script and read the operator gaps — operations.md
- Run the fixtures and tests — testing.md
Porting this into an existing app? Fill in the seam table in adaptation.md first — it takes ten minutes and saves a rename.
Reference directory
| Scenario | Trigger keywords | Reference |
|---|---|---|
| Frontmatter fields, drafts, translation keys, relations | frontmatter, schema, translationKey, draft, related, excerpt | content-model.md |
| Reading files, caching, YAML failures, reading time | gray-matter, fs, cache, memoize, ENOENT, YAMLException, parse | content-loader.md |
| Locales, hreflang, alternates, wrong-locale links | i18n, locale, hreflang, alternates, canonical, language switcher, redirect | i18n-and-routing.md |
| Tag URLs, collisions, thin tag pages | tag, slug, diacritics, collision, tag page, taxonomy | tags.md |
| The routes, metadata, JSON-LD, sitemap, RSS | page.tsx, generateStaticParams, generateMetadata, dynamicParams, sitemap, RSS, feed, BlogPosting | pages-and-seo.md |
| Markdown to React, code blocks, cover images | react-markdown, remark-gfm, syntax highlighting, cover, motif, accent, XSS | rendering.md |
| Validating content, build cost, editor workflow | validation, CI check, build time, drafts, authoring, operator | operations.md |
| Fixtures and the passing test suite | test, fixture, vitest, node:test, regression, assert | testing.md |
| Fitting it into a host app: seams, renames, probe | adapt, port, host, seam, rename, CMS, integrate | adaptation.md |
| The audit ledger: what changed, was kept and was added vs. the earlier implementation | provenance, deviations, fidelity, audit, ledger | provenance.md |