☁️

Next.js を SSG 化しようとして、最終的に React SPA に落ち着いた理由

に公開5

はじめに

こんにちは。株式会社シータグで、自社サービスである BtoB 向けクラウド受発注サービス「受注ハック」の開発に携わっている ritsukei です。

受注ハックのフロントエンドは、もともと Next.js を Amazon ECS Fargate 上でサーバーとして動かしていました。コストと運用負荷を見直す中で、最初に考えたのは「Next.js を残したまま SSG / static export に寄せる」ことです。

ただ、検討を進めると問題は CDN やホスティングの選び方よりも、既存フロントエンドが持っている routing model そのものにあることが分かってきました。最終的には、React Router / Vite ベースの SPA(CSR)として再構成し、Cloudflare Workers Static Assets で配信する構成へ移行しています。

この記事では、

  • なぜ最初は SSG を目指していたのか
  • なぜ途中で方針を変えたのか
  • 実際にどこで詰まり、どう整理し直したのか

を中心に、移行の過程をまとめます。

元の構成:Next.js on ECS Fargate

移行前の構成は次のようになっていました。

  • Frontend: Next.js on ECS Fargate
  • Backend: Go API server on ECS Fargate

フロントエンドとバックエンドは最初から分離されており、Next.js を使ってはいたものの、構成としては「独立した Web フロントエンド + API サーバー」でした。

ただしデプロイ形態としては、Next.js をサーバーとして常駐させる必要があり、

  • コンテナ
  • ECS Service
  • ALB
  • 常駐する Next.js runtime

を維持する構成になっていました。

ここで重要なのは、受注ハックのフロントエンドが Next.js を使っていても、実態としては「独立した Go API server を呼ぶ Web アプリ」だったことです。Next.js API Routes や SSR を中心にした構成ではなく、それでも Next.js runtime は常駐させる必要がありました。

移行前の構成を図にすると、イメージは次のようになります。

最初にやりたかったこと:Next.js を残したまま静的化する

今回の検討は、最初からフレームワーク移行ありきで始まったわけではありません。まずは Next.js をそのまま残しつつ、SSG / static export に寄せて静的ファイルとして配信したいと考えていました。狙いは、常駐 runtime を減らして front-end の運用コストを下げることです。

AWS 側でも、S3 + CloudFront に fallback を設定すれば SPA 配信自体は成立します。実際、deep link だけを見れば AWS でも十分に実現可能です。

一方で、静的配信化を考え始めると staging の保護方法も見直しが必要になりました。

もともと staging では、Next.js middleware を使って Basic 認証をかけていました。
具体的には以下のような流れです。

if (ENV === "stg") {
  // Authorization header を検証
  // 通らなければ /api/basic-auth へ rewrite
  // /api/basic-auth が 401 + WWW-Authenticate を返す
}

つまり staging protection は、Next.js application layer の中に実装されていたことになります。

しかしフロントエンドを純粋な静的配信に寄せると、middleware と API Routes は消えるため、この保護方式はそのままでは使えません。

もちろん AWS 側にも代替手段はありますが、当時は導入コストと運用経験の面から Cloudflare Zero Trust Access が比較的低摩擦でした。ここで見えてきたのは、front-end の静的化に伴って、staging protection の責任が application layer から edge / ingress layer に移ることです。

当時頭の中にあった「静的化後のイメージ」は、おおむね次のようなものでした。

壁になったのは、Next.js pages router 前提の構造だった

ここが今回いちばん大きかったポイントです。

受注ハックのフロントエンドは、当時すでに pure SPA として整理されていたわけではありませんでした。
ルーティングも画面構成も、まだ Next.js pages router を前提にしていました。

  • ページは src/pages/** を中心に構成
  • ルーティングの意味づけも pages router に依存
  • 画面構造そのものが Next.js のページモデルに乗っていた

つまり、議論の中心は「静的ファイルをどこに置くか」よりも、このフロントエンドがどの routing model で動いているのかにありました。

dynamic route が static export と噛み合わなかった

既存の route には、たとえば次のようなものがありました。

  • /reserve/order/[oid]
  • /apply
  • /order-password/reset
パターン static export で難しい理由
動的 ID ルート /reserve/order/[oid] build 時に path を完全列挙しにくい
path は固定だが query / token 依存 /order-password/reset?token=... 実際の画面遷移が runtime state に依存する
初回表示時に API で状態確定が必要 /apply?t=... 生成済み HTML だけでは画面フローが決まらない

たとえば apply や password reset 系のページは、固定 path に見えても実際には query / token を見てから API を叩き、その結果で画面フローが分岐します。こうしたページは build 時点で「この path にはこの HTML を置けばよい」と決め打ちしにくく、Next.js static export の「route ごとに静的成果物を出す」発想と相性が良くありませんでした。

fallback できても、Next.js のままで自然とは限らなかった

ここで改めて整理すると、

  • SPA / CSR のモデル

    • どの未知の path でも最後は同じ index.html に戻す
    • その後はフロントエンドの router が解釈する
  • Next.js SSG / static export のモデル

    • できるだけ build 時にページごとの HTML を出力する
    • 各 route に対応する静的成果物を作る

という違いがあります。

要するに、SPA / CSR は単一入口モデル、Next.js SSG は多ページ静的出力モデルです。

受注ハックが実際にぶつかったのは、後者のモデルを前提にしたフロントエンドを、前者の運用形態へ無理に寄せようとしていた点でした。

Cloudflare や S3/CloudFront に fallback を書けるかどうかよりも、その前提のほうが大きな論点でした。deep link が 404 にならなくなっても、URL パラメータや query の扱い、metadata、portal、認証といった runtime 前提は Next.js のまま残ります。front-end 全体の責務分割まで含めて見直さないと、構成としては綺麗に収束しませんでした。

これは受注ハック固有の事情というより、pages router ベースで育ったフロントエンドを「単一入口の SPA として運用したい」という方向へ後から寄せようとしたときに起こりやすいズレでもあります。
後半で書く適用条件にもつながりますが、今回の判断は Next.js の優劣というより、既存アプリがどの application model に近いのかを見直した結果でした。

このズレは、同じ deep link request を 2 つのモデルでどう扱うかを見ると分かりやすくなります。

特に /reserve/order/[oid]/apply?t=.../order-password/reset?token=... のような route は、build 時に HTML を用意するより、単一の入口で受けてから URL や token を見て画面を決めるほうが自然でした。

受注ハックで苦しかったのは static hosting そのものではなく、既存 front-end の route が「page ごとの静的出力モデル」より「runtime で解釈する単一入口モデル」に近かったことです。

Next.js を残す理由が薄れた

ここまで検討して見えてきたのは、受注ハックの front-end が Next.js を採用していても、実態としては独立した Go API server を呼ぶ Web アプリだった ということです。そうなると論点は、この front-end をどの application model で運用するのが自然かに移っていきます。

受注ハックでは、React SPA として再構成する方向がいちばん素直でした。

互換レイヤーからの段階的移行

移行は一気には行っていません。まずは next/* の shim を用意し、既存コードをなるべくそのまま Vite / React Router 環境で動かすところから始めました。

  • next/router → React Router の適配レイヤー
  • next/linkreact-router-domLink
  • next/headreact-helmet-async
  • next/server → dummy

例として、next/router については最終的には次のように置き換えています。

before

const router = useRouter();
router.push("/reserve/order/123");

after

const navigate = useNavigate();
navigate("/reserve/order/123");

ただし実際の移行では、最初から全面的にこう書き換えたわけではありません。フェーズ 1 では useRouter() 自体を shim し、内部で useNavigate() などをまとめて扱う互換インターフェースを先に作りました。

before / after で見ると、置き換えの軸は次のようになります。

Before After 役割
next/router react-router-dom (useNavigate, useLocation, useParams, useSearchParams) route 遷移 / URL state
next/link Link client-side navigation
next/head react-helmet-async metadata / title / meta tags
src/pages/** 自動解決 App.tsxcreateBrowserRouter(...) route 定義の集約

この段階的移行を挟んだことで、既存コードを一気に壊さずに「まず動く状態を作り、最後に Next.js 依存を抜く」という進め方ができました。

routing を App.tsx に集約

Next.js の pages router を外したあと、routing の責務は React Router 側へ移しました。

最終的には src/App.tsxcreateBrowserRouter(...) を使い、RouterProvider でルート全体を管理する構成になっています。

ここで重要なのは、ページファイルそのものを全部書き直したわけではないことです。ページの多くは引き続き pages/** 相当の構造をかなり残したままにしつつ、

  • ルーティング解決は Next.js ではなく React Router が担当
  • route 定義は App.tsx 側に集約
  • lazy import を使って段階的に読み込む

という形に変えています。

今回の移行は、既存のページ資産を活かしながら routing responsibility を Next.js から切り離していった、と捉えるのが実態に近いです。

実際に外れたのは「画面」だけではなく Next.js runtime 一式だった

完全に Next.js 依存を外した段階で、削除した主なファイルは以下のようなものでした。

  • src/middleware.ts
  • src/pages/_app.tsx
  • src/pages/_document.tsx
  • src/pages/500.tsx
  • src/pages/api/basic-auth/index.ts
  • src/pages/api/health/index.ts
  • next-env.d.ts
  • src/shims/next-*

消えたのは画面ファイルだけではなく、Next.js が暗黙に引き受けていた runtime 上の責務一式でした。責任移転を表にすると次のようになります。

領域 移行前 移行後
routing Next.js pages router React Router
metadata next/head react-helmet-async
app shell / root lifecycle _app.tsx, _document.tsx main.tsx + App.tsx
staging protection Next.js middleware + API Route Cloudflare Access
build / dev server Next.js runtime Vite
API 提供 一部 Next.js にも置ける構成 独立した Go API server に集約
deep link fallback アプリ側前提が曖昧 ホスティング層で明示 (_redirects / not_found_handling)

今回置き換えていったのは、pages/**_app.tsx_document.tsx、middleware、API Routes といった Next.js の標準的な仕組みが暗黙に引き受けていた責務 です。

実際に踏んだバグとホスティング側の調整

移行中に踏んだバグの中で、象徴的だったのが portal 系 UI の問題です。

一部の createPortal を使うコンポーネントが、マウント先を

document.getElementById("__next")

に固定していました。

Next.js ではこれで問題ありません。
Vite SPA に移行すると root element は root になります。

その結果、

  • modal
  • lightbox
  • dialog

といった portal ベースの UI が、target を見つけられずにそのまま壊れました。

このバグは、next/routernext/link の置き換えだけでは移行が終わらないことをよく表しています。DOM root のような runtime 前提まで、既存コンポーネントの中に染み込んでいたわけです。

ホスティング側でも、build が通れば終わりではありませんでした。Cloudflare Pages で検証していた段階では、deep link を正しく開くために _redirects を置いていました。

/* /index.html 200

その後、正式運用を Workers Static Assets に寄せた段階では、これを Cloudflare 側の設定に置き換えています。

{
  "assets": {
    "directory": "./dist",
    "not_found_handling": "single-page-application"
  }
}

つまり deep link の問題は、アプリケーションが SPA として build できることに加えて、ホスティング layer も明示的に SPA fallback を理解している必要があるということです。

デプロイ構成

検証フェーズでは Cloudflare Pages も使いましたが、正式運用は GitHub Actions から build と deploy を明示的に実行する形にしています。

図にすると次のような流れです。

GitHub Actions
  -> yarn install --immutable
  -> .env.production.local を生成
  -> yarn build
  -> dist を生成
  -> wrangler deploy
  -> Cloudflare Workers Static Assets
  -> SPA fallback 付きで配信

たとえば build ステップでは、環境変数を生成してから build しています。

VITE_API_URL=https://api.${DOMAIN}
VITE_FLAVOR=production
VITE_ENV=prd
VITE_SITE_URL=https://${DOMAIN}

デプロイは yarn wrangler deploy で行い、Cloudflare の API token と account ID は GitHub Actions の secrets から渡しています。Wrangler 側では、静的資産と Worker をまとめて扱う形です。

{
  "name": "<worker-name>",
  "assets": {
    "directory": "./dist",
    "not_found_handling": "single-page-application"
  }
}

この構成では、Worker と静的資産を一体として配信しつつ、SPA fallback もホスティング側で明示できます。front-end の build と deploy の責任が CI 側に寄るので、運用の見通しもかなり良くなりました。

SPA 化後の画面表示フロー

ここまでの図は主に構成や配置先を表していましたが、実際の画面表示時にはもうひとつ別の流れがあります。
SPA 化後は、静的ファイルそのものが API を呼ぶわけではなく、index.html を起点に起動した front-end application が runtime で API を呼びます。

この図で分かるとおり、Cloudflare 側は静的ファイルの配信と SPA fallback を担当し、画面データの取得は起動後の front-end application が API を呼びに行います。

どう検証したか

確認の軸はシンプルで、次の 3 つでした。

  • yarn dev / yarn build / yarn preview で Vite + React Router と build 成果物が成立するか
  • deep link やリロード時に SPA fallback が正しく効くか
  • apply や password reset のような token / query 依存ページと API request が崩れないか

特に大事だったのは、画面が開くこと自体より token flow と deep link の成立確認 です。移行後の不具合は、build よりもこのあたりで出やすいと感じました。

この移行方針が向いていた条件

受注ハックでこの方針が成立したのは、次の条件が揃っていたからです。

  • バックエンド API がすでに独立した Go server として存在していた
  • フロントエンドは主に client-side で API を呼ぶ役割だった
  • Next.js API Routes や SSR を中核機能として使っていなかった
  • 画面の主な責務が「routing + API call + state 管理」に寄っていた

逆に、次のようなアプリでは同じ整理はそのまま適用しにくいと思います。

  • SSR が主要要件になっている
  • Next.js API Routes がバックエンドの一部として使われている
  • App Router / Server Components / middleware を積極的に活用している
  • SEO 上、route ごとの事前 HTML 出力が強く要求される

つまり今回の話は、「Next.js より SPA が優れている」という話ではありません。独立 API を持つ既存 front-end に対して、どの application model が自然かを見直した結果だと捉えるのが正確です。

最終構成

最終的な構成は次のように整理されました。

  • Frontend: React SPA(Vite + React Router)
  • Static assets: Cloudflare Workers Static Assets
  • Staging protection: Cloudflare Zero Trust Access
  • Backend: Go API server on ECS Fargate

この形になったことで、front-end と backend の責務がより明確になり、deploy モデルもかなりシンプルになりました。Next.js server runtime を維持する必要がなくなり、staging protection も ingress layer で扱えるようになっています。

最終構成を図にすると、次のようになります。

まとめ

最初にやりたかったのは、単にフロントエンドを静的化して運用を軽くすることでした。
しかし実際に検討を進めると、問題はホスティングの可否よりも、既存フロントエンドのモデルが Next.js static export とあまり噛み合っていないことにあると分かってきました。

今回の移行は、「SSR をきれいに SSG 化した話」より、コスト最適化をきっかけに front-end の application model を見直し、React SPA へ収束していった話でした。

Next.js をやめたかったというより、受注ハックの実態に合う構成へ戻していったというほうが近いかもしれません。

同じように、

  • フロントエンドは独立 API を叩く構成になっている
  • でも deploy はまだ server runtime 前提
  • SSG 化を考え始めたら route model の違和感が出てきた

というケースでは、static export を頑張るより、SPA として整理し直したほうが筋が良いこともあると思います。

株式会社シータグ

Discussion

もんぢゅもんぢゅ

なぜ、Next.jsでSPA(CSR)を行わずに、SSGが向いていないことから、React SPAを採用したのかよくわかりませんでした。

ritsukeiritsukei

コメントありがとうございます。ご指摘の通り、Next.js を残したまま CSR 寄りにする選択肢もありました。

大きく分けると、

  • pages router を残して static export + client-side data fetching に寄せる方法
  • Next.js を単一入口の SPA shell として使う方法

の2パターンになると思います。

前者については、固定 path + query parameter の構成であれば比較的扱いやすいのですが、受注ハックでは /reserve/order/[oid] のような dynamic path が多く、build 時の path 解決や fallback、404 制御を pages router のモデルに合わせて調整する必要がありました。実際に static export を検証した段階でも、placeholder path や router ready guard、404 redirect などの調整が増えていき、SPA fallback との整合を取るための実装コストが高くなっていました。

後者の SPA shell 方式であれば、dynamic path の列挙問題はかなり避けられます。ただ、その場合は routing の主導権が client 側に移るため、next/routernext/linknext/head_app / _document、middleware、API Routes などの責務整理が必要になります。この段階まで進めると、実質的には React Router / Vite への移行とやることがかなり近くなります。

また、Next.js を残した static export 路線では、開発時の Next dev server と本番の Cloudflare 側の static hosting / fallback で runtime model が分かれるため、挙動を揃えるための調整も増えることから、受注ハックの構成では Next.js を SPA shell / build wrapper として残すより、React Router / Vite に寄せて routing・build・hosting fallback の責務を一つの SPA model に揃える方が、シンプルで扱いやすいと判断しました。

記事内ではこの比較が少し省略されていたため、本文にも補足します。

もんぢゅもんぢゅ

返信ありがとうございます。
私が感じたのは単純に、最初にssr:falseなどで全体をCSR化してしまえば良かったのではないかという疑問です。
App Routerの場合はReactのサーバーコンポーネントの導入が強くCSR化は負担が大きいのですが、Page Routerであればそこまで負担なく全体をCSR化することができるのではと思いました。
それを上回るほど責務整理の負担が大きくなると感じた部分や判断があったのであろうと思います。ただ、上記投稿だとCSR化をどこで断念したのかがわからなかったです。

だるだるしだるだるし

物凄く素直に思ったことを書くと
まずスタートの技術選定に失敗してると思うし、AWSを使用するというのも失敗してると思うし、
SSGを目指したのも失敗だと思うし
これは異論覚悟で言うけれども、今Reactというのもちょっと違う気がしていて

ベンチャーなのか長寿サービスなのか知らないけれど
カッコつけたのベンチャーの罠に全部引っかかっているように思われ

そもそも、ECSケチらないといけないって多分サービスとしての見積から誤っているのでは
勿論それらが筆者さんの問題と思っているわけではなく
そうした、そう考えた色々な人が関わっているのだろうが

タイトルが満たされないのは兎も角
問題の本質は全然別の所にあるのではないか…

ritsukeiritsukei

貴重なご意見ありがとうございます。今後の参考にさせていただきます。