🏗️ ISRをやめてSSGにした話

tl;dr

ISR + NextAuth + 自作 CMS + Vercel のフルスタック構成から、SSG + Cloudflare Pages のシンプルな静的サイトに移行した。認証も記事管理も全部削除して、ビルド時に HTML を生成して配信するだけの構成に。

移行前のアーキテクチャ: Vercel上のNext.js 15でISR・NextAuth認証・記事管理を含むフルスタック構成 Before

移行後のアーキテクチャ: Directus CMSからCloudflare Pages経由で静的配信するSSG構成 After


※ これはインターネット上に数多あるCloudflare Pagesへの移管記事である。

このブログはもともとISR(Incremental Static Regeneration)で動いていた。 Next.js + Hasura GraphQL + PostgreSQL という構成で、NextAuthによる認証や記事管理の CRUD、Intercepting Routesまで入ったやりたいこと全部盛り構成だった。

今回それを全部やめてSSG(Static Site Generation)にした。ビルド時にHTMLを生成してCloudflare Pagesに置くだけ。その経緯をまとめておく。

なんでそんな構成だったのか

もともとの動機はシンプルで、ISRを実践で使ってみたかったから。Next.jsの特徴的な機能だし、業務にも関わるかもしれなかったので自分のブログで試しておきたかった。

あと、業務でCMS開発に携わることがあり、プライベートでも自作CMSを運用してみたかった。HasuraがPostgreSQL の前段に入ってGraphQL APIを提供してくれるので、バックエンドのコード量は最小限で済む。

当時のアーキテクチャはこんな感じだった。

移行前のアーキテクチャ: Vercel上のNext.js 15でISR・NextAuth認証・記事管理を含むフルスタック構成

閲覧者と管理者の両方がCloudflare CDN経由でVercelにアクセスする構成。Next.js内にISR、NextAuth 認証、記事管理が同居していて、Apollo Client経由でHasura GraphQL → PostgreSQLに繋がっている。年に数回しか更新しないブログにしてはちょっとやりすぎ。

理想と実態のギャップ

で、実際どうだったか。

記事の執筆フローが「Markdownを別のエディタで書いて、自作CMSにコピペする」という運用になっていた。CMSの記事エディタも使い心地が良くなく、最初に実装して以来一度も改修してない。改善のモチベーションがまったく湧かなかった。

そもそも記事の更新頻度が年に数回。ISRの「リクエスト時に再生成」という仕組み、年数回の更新に対して完全にオーバーエンジニアリングだった。ビルド時に全ページ生成すれば十分じゃんと考えた。

自作CMSも結局コピペ先としてしか使っていないならCMSである必要がない。

移行のきっかけ

Vercelの機能はそれなりに使っていた。ISR、next/image の最適化、@vercel/analytics。でもSSGブログに本当に必要だったかというと、どれもなくて困らないものばかりだった。むしろ気づけば Vercel のランタイムに依存した機能がコードベースに散らばっていて、Vercelなしでは動かない状態になっていた。将来ホスティング先を変えたくなったときに、移行コストが膨らむ一方だと感じた。

サイトの構成をシンプルにしたかった。 これが一番の動機だと思う。

移行先はCloudflare Pages。Pages + R2 + DNS + Deploy Hooks と、Cloudflare エコシステムに統一することで拡張性も確保できる。

Vercel Hobby vs Cloudflare Pages Free

公式ドキュメントベースで比較するとこんな感じ。

項目Vercel HobbyCloudflare Pages Free
商用利用非商用のみ制限の記載なし
帯域幅100 GB/月無制限
アナリティクス50,000 イベント/月無制限・無料
ビルド回数制限なし(100時間/月)500回/月
デプロイ回数100回/日無制限

静的サイトを無料でホスティングするなら Cloudflare Pagesで十分と判断。

移行でやったこと

移行は 7 フェーズに分けて進めた。全部書くと長くなりすぎるので要点だけ。

Phase 1 - 移行方針の検討: ISRのメリットを活かせていなかった。記事の更新頻度は年に数回で、リクエスト時に再生成する仕組みは不要。それならSSGにして静的ファイルを配信するだけの構成にすることを決定。

Phase 2 - 計画立案: 作業順序の策定。何を消して何を残すか整理。

Phase 3 - SSG 化実装: ここが一番大きい。NextAuthの認証、記事管理のCRUDページ、Intercepting Routeを全部削除。next.config.jsoutput: 'export' を設定して、imagesunoptimized: true を追加。これでNext.jsが純粋な静的サイトジェネレーターになる。generateStaticParams でビルド時に全記事のパスを生成して、Apollo ClientでHasuraから記事データを取得する流れだけは残す。

Phase 4 - デプロイ構成: Cloudflare PagesのGit連携でpush時に自動デプロイ。加えてGitHub Actionsの workflow_dispatch で手動リビルドもできるように。ワークフロー内では wrangler pages deploy out/ で静的ファイルをアップロード。

Phase 5 - Vercel 完全離脱: @vercel/analytics 等のVercel関連パッケージと設定を全削除。Cloudflare DNSの向き先をVercelからCloudflare Pagesに切り替え。

Phase 6 - 記事管理方針: HasuraのPostgreSQLはそのまま維持しつつ、記事の編集はDirectus CMSに移行する方針に決定。自作CMSにさよなら 👋。

Phase 7 - 自動リビルド: 最初はGitHub Actionsの repository_dispatch で自動リビルドを組もうとしたが、CloudflareのDeploy Hooks方式に変更した。Directus FlowsからWebhookを飛ばしてリビルドをトリガーする流れ。既に別プロジェクトでGitHub Actionsを動かし過ぎて月の無料枠に達し、全リポジトリでActionsが使えなくなっていたのが正直な理由。結果としてCloudflare側で完結するほうがシンプルだなとも思った。

ハマりどころ

いくつか技術的にハマったポイントを記録しておく。

Apollo Client v3 → v4 の破壊的変更

これはSSG移行とは直接関係ない。移行作業の少し前にRenovateがv4のアップグレードPRを作ってくれて、対応したらビルドが出来なくなった。v3のままでもSSG化自体は問題なくできたはずだけど、タイミング的に重なったので一緒に片付けた形。

rxjs の Module not found エラーのスクリーンショット

まず Module not found: Can't resolve 'rxjs' で落ちる。Apollo Client v4は内部の Observable 実装を zen-observable-ts から RxJS に置き換えたため、rxjs が必須のpeer dependencyになった。

次にインポートパスの変更。React hooks 系のインポート先が @apollo/client から @apollo/client/react に変わっていて、全ファイルで書き換えが発生した。GraphQL Code Generatorを使っている場合は codegen.ts にも設定が必要:

config: {
  apolloReactHooksImportFrom: '@apollo/client/react',
},

あと ApolloClient の型が非ジェネリックになったApolloClient<NormalizedCacheObject>ApolloClient に変わるので、Apollo Clientインスタンスの型定義も修正が必要。

Type 'ApolloClient' is not generic. のエラーのスクリーンショット

エラーメッセージ自体は分かりやすくて助かった。どれもマイグレーションガイドに目を通してからアップグレードしていればハマらなかった話ではある。

Before / After

項目BeforeAfter
レンダリングISRSSG
ホスティングVercelCloudflare Pages
認証NextAuthなし
記事管理自作 CMSDirectus CMS
バンドラーWebpackTurbopack
デプロイGit push → Vercel 自動Git push → Cloudflare Pages + Deploy Hooks

Cloudflare のエコシステム(Pages + R2 + DNS + Deploy Hooks)に寄せたことで、全体がスッキリした。HasuraとPostgreSQLはそのまま残しているので、データ層の柔軟性は維持できている。サーバーランタイムは不要。ビルドして out/ に吐かれた静的ファイルをそのまま配信するだけとなった。

自作CMSからDirectusへ(Directusという選択肢)

自作CMSをやめてDirectus CMSに移行することにした。

自作CMSは「作ること自体が目的」みたいなところが正直なところあり、実用性は皆無だった。DirectusはデータベースファーストのヘッドレスCMSで、既存のDBスキーマをそのまま読み取って管理画面とAPIを自動生成してくれる。だからDBスキーマを変えずにCMSだけ乗り換えられる。管理画面も標準で提供されており、Webhook(Directus Flows)で外部連携もできる。

もうCMSを作るのはやらなくていいかな。でも既存のDBとスキーマはそのまま活かしたいなと思っていたのでデータベースファーストのDirectusはこの要件にちょうど合っていた。

今後やりたいこと

  • タグ機能の追加
  • OGP画像の対応
  • ダーク / ライトのテーマ切り替え

どれも「あったらいいな」レベルなので、気が向いたらやる。

おわりに

ISR + 自作 CMS + Vercel という「できることが多い構成」から、SSG + Directus + Cloudflare Pagesという「必要十分な構成」に移行した。機能は減ったが運用はラクになり、コードもシンプルになった。

ちなみに、移行中のダウンタイムはゼロだった。Cloudflare PagesのGit連携で新環境を先に立ち上げて、*.pages.dev で動作確認してからDNSの向き先をVercelからCloudflare Pagesに切り替え、最後にVercelを停止する順番で進めたので、サイトが見れない時間は発生していない。

ISRも自作CMSもIntercepting Routeも、触っているときは純粋に楽しかった。使ってみて初めて「これは自分のユースケースには合わない」と判断できるようになるし、技術選定を失敗したと思える経験ができたのは大きい。失敗しないとわからないことって結構ある。

では。