Next.js の静的エクスポートでmicroCMSを繋ぐ — サーバーを持たずに下書きプレビューまで用意する
石塚 凪(代表取締役社長 兼 CEO)公開日: 最終更新: 
- Next.js
- App Router
- microCMS
- 静的サイト
このテックブログは Next.js の output: 'export' で完全に静的化し、S3 + CloudFront で配信しています。サーバーもサーバーレス関数も持ちません。
その代わりに得られるのは、アプリケーション実行時の障害要因が無くなることです。リクエストごとに動くコードが存在しないため、実行時エラーもコールドスタートもありません。キャッシュヒット時のTTFBは配信網のレイテンシにほぼ一致します。
ただし障害がゼロになるわけではありません。CDN・ストレージ・DNS・証明書の問題は残りますし、キャッシュミス時はオリジンへの応答時間が乗ります。監視の対象が変わるだけで、無くなるわけではないという理解が正確です。
一方で、Next.js の便利な機能はかなりの数が使えなくなります。何が落ちて、何で埋めるのかを整理しました。
使えなくなるものと、その代替
- ISR・revalidate → microCMSのWebhookで再ビルド
- Draft Mode → CSRのプレビューページ + draftKey
- Server Actions・Route Handlers → 外部SaaS
- middleware → CloudFront Functions
- next/image のデフォルトloader → カスタムloader
並べると多く見えますが、コンテンツが編集者の操作でしか変わらないサイトでは、いずれも実質的な機能低下になりません。更新頻度が「1日に数回」なら、再ビルドはリアルタイム性の要件を満たします。
動的ルートは generateStaticParams が必須
記事詳細のような動的ルートは、ビルド時に全URLを列挙します。dynamicParams を false にして、列挙されていないURLは404にします。
この方式では、記事を公開してからビルドが完了するまでの数分間、そのURLは404になります。公開直後にURLを共有する運用があるなら、ビルド完了を待ってから共有する手順にしておいてください。ユーザー生成コンテンツのように実行時にURLが増えるものは、この構成では扱えません。
src/app/articles/[id]/page.tsx
export const dynamicParams = false;
export async function generateStaticParams() {
const articles = await getArticles();
return articles.map((article) => ({ id: article.id }));
}Webhookで公開を自動化する
microCMSのWebhookには「GitHub Actions」タイプがあり、中継サーバーを立てずに repository_dispatch を飛ばせます。ワークフロー側でイベント名を受けるだけです。
.github/workflows/deploy.yml
on:
push:
branches: [main]
repository_dispatch:
# microCMS Webhook のトリガーイベント名と完全一致させること
types: [microcms-update]
workflow_dispatch:下書きプレビューはCSRで用意する
Draft Mode はサーバーが要るので使えません。代わりに ブラウザから直接microCMSを叩くプレビュー専用ページを用意します。microCMSの「画面プレビュー」の遷移先URLに、コンテンツIDとdraftKeyを埋め込んでもらう形です。
https://tech.becalm.co.jp/preview/articles/?id={CONTENT_ID}&draftKey={DRAFT_KEY}ここで注意したいのがAPIキーの権限です。プレビュー用キーは NEXT_PUBLIC_ として公開JSに平文で載るため、権限を絞らないと誰でも叩ける状態になります。
- GETのみを有効にする
- 「下書きコンテンツの全取得」は付けない(1件取得には draftKey だけで足りる)
- 対象APIを限定する(他のAPIと同じサービスを共用している場合はとくに重要)
- ビルド用キーとは必ず別のキーにする
draftKeyはコンテンツごとに発行される秘密値なので、GET専用キーが露出しても下書きはdraftKeyを知っている人しか読めません。ここが権限設計の要です。
取得層と変換層を分ける
プレビューページはクライアントコンポーネントなので、node:fs を使うビルド時専用のモジュールを読み込めません。純粋な変換(CMSのレスポンス → 表示用の型)と、取得を別モジュールに分けておく必要があります。
この分離を怠ると、プレビューを作る段階でクライアントビルドが通らなくなり、変換ロジックを二重に持つ羽目になります。最初から分けておくのが安全です。
よくある質問
Q. 静的エクスポートだと更新反映まで何分かかりますか?
このサイト(記事数十本規模)では約5分です。内訳はWebhookの発火、GitHub Actionsでのビルド、S3への同期、CloudFrontのキャッシュ無効化です。
この数字は記事数・画像枚数・CIの並列度で変わります。ページ数に比例して伸びるのはプリレンダリングの時間で、CMSからの取得をビルド前の1回にまとめておけば、そこがボトルネックになることはありません。数千ページ規模まで伸ばすなら、ビルドの分割や差分デプロイの検討が要ります。
Q. プレビューページは検索エンジンにインデックスされませんか?
プレビュー用のレイアウトで robots: { index: false, follow: false } を宣言し、robots.txt でも /preview/ を除外します。sitemapにも載せません。加えてアクセス解析からも除外しておくと、編集者の確認作業が数値に混ざりません。
まとめ
静的エクスポートの制約は「機能が使えない」というより、同じ目的を別のレイヤーで実現するという読み替えで整理できます。ISRはWebhook、Draft Modeはプレビューページ、middlewareはCDNのエッジ関数。置き換え先を決めてから着手すれば、行き止まりにはなりません。