Adding a Multilingual Blog to a Statically Exported Next.js Site
This site is a static export (output: 'export') hosted on Cloudflare Pages. There is no server. So the first thing to decide when adding a tech blog was where the posts should live.
Why not an external CMS
I looked at using the Notion API, but it fits poorly with a static export.
- Images are served through signed URLs that expire, so every build has to download them and keep a local copy
- Post bodies come back as nested blocks, which means implementing recursive fetching and pagination
- The build depends on an external API. If it goes down, so does the deploy
- Publishing a post does nothing to the repository, so you need a separate mechanism to trigger a rebuild
In the end, keeping posts as Markdown files in the repository was by far the simplest option. The build just reads files, so there is no network access at all.
Directory layout
Each post is a folder, with one file per language.
blogs/tech/<slug>/
meta.json shared across every language
ja-JP.md the source
en-US.md a translation
ko-KR.md only the languages that existPublication date and tags live in meta.json so they cannot drift between translations. Without that, you eventually end up with a Japanese version and an English version of the same post claiming different publication dates.
{
"date": "2026-08-12",
"tags": ["Next.js", "i18n", "Cloudflare Pages"],
"draft": false,
"sourceLocale": "ja-JP"
}Only title and description change per language, so those stay in the frontmatter of each Markdown file.
Not generating routes for missing translations
This site has 23 locales, but a post is never available in all 23 from day one. Translations always lag behind.
The tempting shortcut is to fall back to English when a translation is missing. That produces a Japanese URL serving English content — twenty-two thin, duplicated pages as far as a search engine is concerned. Worth avoiding.
Instead, routes are generated only for the languages whose files actually exist.
export const listPostParams = (
category: BlogCategory
): { locale: LocaleCode; slug: string }[] =>
listSlugs(category).flatMap((slug) =>
localesOf(category, slug).map((locale) => ({ locale, slug }))
)localesOf does nothing more than look at the *.md files in the folder. Drop in ko-KR.md and the Korean route appears; leave it out and it does not. Adding a translation never requires touching code.
hreflang and sitemap.xml are built from the same list. Never emitting links to translations that do not exist turned out to be the most important part of this setup.
Detecting stale translations
Once you commit to translating every language, the next problem is knowing which translations went stale after you edited the source. Re-translating 22 languages for a single typo is not realistic.
So meta.json records a hash of the source as it was at translation time.
export const hashSource = (markdown: string): string =>
createHash('sha256')
.update(matter(markdown).content.trim())
.digest('hex')
.slice(0, 16)It hashes the body only, with the frontmatter stripped, so adjusting a title does not invalidate anything. Edit the source body and the hash no longer matches, which identifies exactly which translations have fallen behind.
Turning Markdown into HTML
I wanted syntax highlighting resolved at build time, so I used Shiki. A highlighter created through createHighlighter exposes a synchronous codeToHtml, which means it can be called directly from the Markdown renderer.
const highlighter = await createHighlighter({
themes: [THEME],
langs: LANGS
})
const marked = new Marked({
gfm: true,
renderer: {
code({ text, lang }) {
const language = lang && loaded.has(lang) ? lang : 'text'
return highlighter.codeToHtml(text, { lang: language, theme: THEME })
}
}
})The generated HTML carries inline styles, so nothing extra is shipped to the browser — no runtime JavaScript, no additional stylesheet. For a static site, that was enough.
Result
Three dependencies in total, gray-matter among them, and a build that depends on no external service. Write a post, push, and it deploys.
To add a language, drop in a <locale>.md. The URL, the hreflang entry, and the sitemap.xml entry all follow from that.