Becalm Tech BlogTECH BLOG
フロントエンド

Next.js の静的エクスポートでmicroCMSを繋ぐ — サーバーを持たずに下書きプレビューまで用意する

石塚 凪(代表取締役社長 兼 CEO)公開日: 最終更新:

このテックブログは 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のエッジ関数。置き換え先を決めてから着手すれば、行き止まりにはなりません。

Becalmで、一緒につくりませんか。

エンジニア・デザイナーを募集しています。カジュアル面談も歓迎です。