ブログ

静的エクスポートの Next.js に多言語ブログを足す

Next.js
i18n
Cloudflare Pages
Shiki

このサイトは 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"
}

言語ごとに変わるのは titledescription だけなので、そちらは各 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 を置けば韓国語のルートが生え、置かなければ生えない。翻訳を足すたびにコードを触る必要がない。

hreflangsitemap.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 も hreflangsitemap.xml も付いてくる。