Next.js個人ブログを脱CMSした話(microCMS → Velite + MDXへ1日で移行)

目次
こんにちは!@Ryo54388667です!☺️
普段は都内でエンジニアとして働いています!主にTypeScriptやNext.jsを触っています。
今回は 個人ブログを脱CMSした話 を紹介していきます!
このブログはずっとmicroCMS(ヘッドレスCMS)で記事を管理していたのですが、先日、記事をまるごとリポジトリ内のMDXファイルへ引っ越しました。
ヘッドレスCMSからの移行を考えている方、Next.jsブログのコンテンツ管理で悩んでいる方に参考になると思います!
何をしたのか
#microCMSで管理していた記事を、フロントエンドのリポジトリ内にMDXファイルとして取り込みました。いわゆる「脱CMS」です。
数字でまとめるとこんな感じです。
| 項目 | 内容 |
|---|---|
| 移行対象 | 記事60本(日英30ペア)+ 画像87点 + カテゴリ19件 |
| 移行方式 | microCMS → リポジトリ内MDX(Velite) |
| 期間 | 計画から本番切替・完全撤去まで1日 |
| 変換ロス | ゼロ(60記事すべてMDXコンパイル成功) |
| 画像 | 58.79MB → 8.91MB(84.8%削減) |
| URL | 完全維持(リダイレクト設計不要) |
| おまけ | 旧実装のバグを7個発見して修正 |
個人的なハイライトは最後の行です。
CMSを剥がすだけのつもりが、移行前からずっと潜んでいたバグが7個も見つかりました。この話は後半で詳しく書きます。
なぜ脱CMSしたのか
#理由は大きく4つです。
- 記事の実体がCMS上にしかなく、Gitでバージョン管理できない
- ビルドのたびにmicroCMSのAPIを叩いていて、API障害やキー未設定でビルドがこける
- 好きなエディタやAIツールで執筆して、PRレビューを通して公開したい
- 無料枠の制約から解放されたい
特に大きかったのは1つ目と3つ目です。
microCMSのコンテンツAPIは公開済みの記事しか返しません。更新系のAPIも存在はするのですが、自分の運用では組み込めておらず、日々の編集は実質、管理画面のリッチエディタだけでした。
「記事の公開 = git push」にしたいんですよね。エンジニアなら一度は思うやつだと思います。
移行先の構成
#移行後のコンテンツはこんな配置になりました。
content/
blogs/
{slug}/
index.ja.mdx # 記事本体(日本語)
index.en.mdx # 英語版とペアで置く
images/ # 記事画像もここに同居
categories.json # カテゴリマスタ
ogp-cache.json # リンクカード用のOGPキャッシュ
コンテンツレイヤーには Velite を採用しました。frontmatterをZodスキーマで検証して、TypeScriptの型まで自動生成してくれるライブラリです。
技術選定で印象的だったのが、この分野の栄枯盛衰です。
一時期の定番だったContentlayerは本家の開発が止まり、next-mdx-remote も2026年4月にリポジトリがアーカイブされました。数年前の「定番」が軒並み止まっているんですよね。。
一方で、土台のエコシステム(unified / remark / rehype)は健在です。
なので「ラッパーはいつか死ぬ」前提で選定しました。仮にVeliteが止まっても、手元に残るのはただのMDXファイルです。いつでも自前のunified実装に退避できるという保険があるだけで、だいぶ気が楽になります。
移行で最初に決めた4つの原則
#作業に入る前に、Web上の脱CMS事例をいくつも読みました。
先人がハマっている場所は共通していて、「URLやSEO維持の設計が手薄」「画像管理が煩雑になる」「試したい技術を詰め込みすぎる」「移行後に記事を書きにくくなる」あたりに集約されます。
これを裏返して、原則を4つ決めてから手を動かしました。
- URL完全維持: slugをmicroCMSのコンテンツIDと一致させ、リダイレクト設計自体を不要にする
- コンテンツフリーズ: 変換開始から切替まで、CMS側の記事更新を凍結する
- パリティゲート: sitemap・RSS・見た目の移行前後比較に合格するまで旧実装を撤去しない
- 解約は最後: バックアップ確認 → 本番の安定稼働 → 撤去 → 解約、の順番を守る
「詰め込みすぎ」対策としては、移行中に湧いてきた「シンタックスハイライトをShikiに変えたい欲」を封印しました。見た目を変えるとビジュアル回帰テストの判定が汚染されるので、移行後の別Issueに回しています。
あとは変換設計の前に、全記事のエクスポートデータをjqで全数調査しました。
colspan="1" という属性が1176個も出てきて一瞬焦ったのですが、正体はmicroCMSのエディタが全セルに付けるただのノイズ。「数字だけ見ると恐いけど実態は無害」というパターンが多くて、この事前調査が後の設計をずっと守ってくれました。
最大の難所は見出しidの保持
#本文の変換は、microCMSのHTMLをunified公式のAST変換(rehype-parse → hast-util-to-mdast → mdast-util-to-markdown)でMDXへ落とす方式にしました。Tweetやリンクカードの埋め込みは <Tweet /> のようなJSXコンポーネントへ置き換えます。
変換自体は素直に進んだのですが、1つだけ大ハマりしたのが見出しidです。
microCMSの見出しには <h2 id="hba7e17d1c0"> のような自動生成idが付いていて、目次と共有リンクのアンカーがこれに依存しています。ここを雑に変換すると、過去に共有された「#見出し付きURL」が全部死にます。
最初は「{#custom-id} 記法でいけるやろー」と軽く考えていました。
甘かったです。。MDXは {...} をJSX式として解釈するので、acornのパースエラーで即死します。しかもこの記法は MDX公式が対応しない方針を明言しています。
「なら <h2 id="..."> を直書きすればいいのでは?」と思いますよね。
これもダメでした。MDXのcomponentsマップはJSXリテラルには適用されないので、既存の見出しコンポーネントを素通りしてスタイルが消えます。
最終的に採用したのは3案目です。本文は普通のMarkdown見出しのまま書き、idだけをfrontmatterに文書順で保存して、rehypeプラグインで順番に注入します。
headingIds:
- hba7e17d1c0
- h9036816da0
# 文書順。h1〜h6の全見出しに順番どおり割り当てる
let i = 0;
visit(tree, "element", (node) => {
if (/^h[1-6]$/.test(node.tagName)) {
if (headingIds[i]) node.properties.id = headingIds[i];
i++;
}
});
これでようやく、目次も共有リンクも移行前と同じ挙動になりました。
実は1案目を採用する前提でIssueを書きかけていて、「本当にMDXで動くのか?」と実機PoCをしたら全滅した、という流れでした。実装前に潰せて本当に良かったです😅
Cloudflare Workersの制約でISRを捨てた
#このブログはCloudflare Workersで動いているのですが、ここでも時限爆弾を1つ踏みかけました。
Veliteの s.mdx() が出力するのはfunction-body形式の文字列で、レンダリングするには new Function(code) の評価が必要です。
一方で、Cloudflare Workersのランタイムは 動的コード生成(evalやnew Function)を禁止 しています。
ビルド時のSSGはNode上で動くので問題ありません。ただ、記事詳細ページは revalidate = 86400 のISRにしていたので、このままだと再検証が走った瞬間にWorker内でレンダリングが始まり、EvalErrorで死にます。
ローカルでは何も起きず、本番の再検証時に初めて死ぬやつです。怖すぎる。。😇
対応はシンプルで、ISRを廃止して完全静的化(revalidate = false)しました。
考えてみれば、コンテンツがリポジトリ内で確定するようになった以上、「記事の更新 = 再デプロイ」です。ISRで再検証するものが何もありません。CMS時代の設定が脱CMS後は不要どころか有害になっていた、という気持ちのいい整理でした。
移行作業で旧実装のバグが7個見つかった
#移行の受け入れ条件は「移行前後で出力が一致すること」にしていました。
本番のsitemap・RSS・llms.txtのスナップショットを事前に取得してコミットしておき、新実装の出力とのdiffを取ります。見た目もPlaywright + pixelmatchで63ページ分のビジュアル回帰を回しました。
URL網羅やLighthouseの比較まで含めると、検証スイートは全部で8種類です。
「そんなにきれいに一致するわけなくない?」
実際、一致しませんでした。ただ、一致しない箇所を調べていくと出てくるのは新実装のバグではなく、ほとんどが旧実装のバグだったんです。
移行作業の全体で見つかった旧実装のバグは7個です。
| # | バグ | 発見の経緯 |
|---|---|---|
| 1 | 英語版sitemapのURLに日本語記事のカテゴリを流用 | スナップショットdiff |
| 2 | 英語版sitemapのlastmodが日本語記事の値(56件) | スナップショットdiff |
| 3 | 旧URLリダイレクトでクエリ文字列が消える | e2eの書き直し |
| 4 | Paginationのテスト12件が常に赤 | テスト移行 |
| 5 | e2eの大半が存在しないセレクタで素通り | テスト移行 |
| 6 | 長いコードブロックでページ全体が横スクロール | ビジュアル回帰 |
| 7 | 本文画像へのwidth/height注入が全滅 | 画像圧縮の検証 |
5番は衝撃でした。
記事カードを探すセレクタが <article> タグ前提なのに、実装は <li> ベース。永遠にマッチしないうえ、「要素が見つからなければ何も検証せずパス」という書き方だったので、グリーンに見えて実質何もテストしていませんでした。
「テストが通っている」と「テストしている」は別物なんですよね。。
6番も根深くて、原因はflexアイテムのデフォルト min-width: auto という、知らないと絶対にハマるCSSでした。内側で overflow-x: auto にしても、コードブロックの実幅がflexアイテム自体を押し広げてしまうんです。min-w-0 の明示で根治しました。
ちなみに切替後の本番動作確認でも、新実装側の問題を3つ見つけました。
中でも「存在しない記事URLが404ではなく500を返す」は、generateMetadata がページ本体より先に評価されて例外処理をすり抜けていたのが原因でした。同じ問題はOGP画像のルートにもあり、あわせて修正して回帰テストを足しています。
クローラー目線ではSEOの実害があるので、切替チェックリストには正常系だけでなく異常系も入れるべき、という教訓です。
執筆ワークフローも作り直した
#脱CMSの後悔談で一番よく見かけるのは「移行したら記事を書きにくくなった」です。なのでここは独立したフェーズとして設計しました。
npm run new-post -- <slug>で記事の雛形を生成(draftフラグ付き)- draft記事は本番ビルドの出力自体から除外(一覧・検索・sitemap・RSSまで、1箇所のフィルタで全経路をカバー)
- CIで内部リンク切れチェック + textlint + markdownlint + frontmatterのスキーマ検証を強制
おもしろかったのはtextlintです。
技術文書向けのプリセットを既存の30記事にそのままかけたら、エラーが1048件出ました笑
中身を見ると、ほぼ全部が感嘆符・絵文字・弱い表現といった「個人ブログの文体」への誤検知です。コンテンツフリーズの原則がある以上「記事を直してlintに合わせる」は禁じ手なので、文体系のルールをoffにして、lint側を現実に合わせました。
記事を機械に合わせはじめたら本末転倒ですからね。
最後に
#やってみて特に効いた学びはこのあたりです。
- URL維持を最優先に設計すると、リダイレクトもSEO対策も「不要」にできる
- パリティ検証は移行の答え合わせであると同時に、旧実装の監査になる
- プラットフォーム制約は早めに洗う。ローカルで動く ≠ 本番で動く
- 実データの全数調査が設計を守る(ゴミに見えたデータが現役記事のURL構成要素だったことも)
- 執筆体験は独立フェーズで設計する
- ラッパーライブラリはいつか死ぬ。中身が素のMDXであることが一番の保険
残作業も少しだけあります。しばらくはSearch Consoleとエラーレートの監視を続けて、安定稼働を確認できたらmicroCMSを解約する予定です(コンテンツのバックアップは確保済み)。
ちなみにこの移行、計画の起票から完全撤去まで1日で終わっています。実装の大部分はClaude Codeに任せて、自分は設計判断と要注意ポイントのレビューに集中する進め方でした。このあたりのAIエージェント運用の話は、また別の記事で書きたいと思っています。
脱CMSを検討している方の参考になれば幸いです!
より良い方法があれば教えてください〜
最後まで読んでいただきありがとうございます!
気ままにつぶやいているので、気軽にフォローをお願いします!🥺
