静的エクスポートの Next.js に多言語ブログを足す
このサイトは output: 'export' の静的エクスポートで、Cloudflare Pages に置いている。サーバーは無い。そこに技術ブログを足すとき、最初に決めるのは「記事をどこに置くか」だった。
外部 CMS を使わなかった理由
Notion API を使う案も検討したが、静的エクスポートとは相性が悪い。
- 画像が署名付き URL で配られるため、しばらくすると失効する。ビルド時に全部ダウンロードして手元に置く処理が要る
- 本文がブロックの入れ子で返るので、再帰取得とページネーションの実装が必要になる
- ビルドが外部 API に依存する。相手が落ちるとデプロイも落ちる
- 記事を公開してもリポジトリには何も起きないので、再ビルドを起こす仕組みを別に用意することになる
結局、記事を Markdown ファイルとしてリポジトリに置くのが一番簡単だった。ビルド時にファイルを読むだけなので、ネットワークアクセスがゼロになる。
ディレクトリ構成
記事 1 本を 1 フォルダにして、言語ごとにファイルを分けた。
blogs/tech/<slug>/
meta.json 全言語で共有する情報
ja-JP.md 原文
en-US.md 翻訳
ko-KR.md 訳した分だけ置く公開日やタグを meta.json に切り出しているのは、翻訳の間でズレるのを防ぐためだ。同じ記事なのに日本語版と英語版で公開日が違う、という事故が起きなくなる。
{
"date": "2026-08-12",
"tags": ["Next.js", "i18n", "Cloudflare Pages"],
"draft": false,
"sourceLocale": "ja-JP"
}言語ごとに変わるのは title と description だけなので、そちらは各 Markdown の frontmatter に置いている。
翻訳が無い言語のルートを作らない
このサイトは 23 言語ある。だがブログの記事が最初から 23 言語揃うことはない。翻訳は必ず後追いになる。
ここで「訳が無ければ英語にフォールバックする」をやると、URL は日本語なのに中身が英語、という状態になる。検索エンジンから見ると価値の薄い重複ページが 22 個生えることになるので、避けたい。
そこで、ファイルが存在する言語のぶんだけルートを生成するようにした。
export const listPostParams = (
category: BlogCategory
): { locale: LocaleCode; slug: string }[] =>
listSlugs(category).flatMap((slug) =>
localesOf(category, slug).map((locale) => ({ locale, slug }))
)localesOf はフォルダ内の *.md を見るだけの関数だ。ko-KR.md を置けば韓国語のルートが生え、置かなければ生えない。翻訳を足すたびにコードを触る必要がない。
hreflang と sitemap.xml も同じリストから作っている。存在しない翻訳のリンクを書かないことが、この構成でいちばん大事な部分だった。
翻訳が古くなったことを検出する
全言語を訳す運用にすると、次は「原文を直したときに、どの訳が古くなったか」が分からなくなる。誤字ひとつ直すたびに 22 言語を訳し直すのは現実的ではない。
meta.json に、訳した時点の原文のハッシュを残すことにした。
export const hashSource = (markdown: string): string =>
createHash('sha256')
.update(matter(markdown).content.trim())
.digest('hex')
.slice(0, 16)frontmatter を除いた本文だけを対象にしているので、タイトルの調整では古くならない。原文の本文を直すとハッシュがズレて、追随できていない訳が特定できる。
Markdown から HTML への変換
コードハイライトはビルド時に済ませたかったので、Shiki を使った。createHighlighter で作ったハイライターは codeToHtml が同期なので、Markdown パーサのレンダラーからそのまま呼べる。
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 })
}
}
})生成される HTML にはインラインのスタイルが乗るので、実行時に読み込む JS もスタイルシートも増えない。静的サイトに置く分には、これで十分だった。
結果
追加した依存は gray-matter を含めて 3 つだけで、ビルドは外部サービスに一切依存しない。記事を書いて push すれば、そのままデプロイされる。
言語を足したいときは <locale>.md を置く。それだけで URL も hreflang も sitemap.xml も付いてくる。