この10年ほどで、SPA(Single Page Application)はフロントエンド開発の標準的な構成になりました。React RouterやVue Routerを導入し、画面遷移と状態管理をブラウザ側へ集約する設計は、複雑な操作を途切れさせずに提供できます。

一方で、SPAが向いているかを検討する前に、SPAを前提としてプロジェクトを始める例も増えました。ページ数が多く、利用時間が長い業務システムでは、ルーター、認証状態、キャッシュ、イベントリスナー、非同期処理が一つのDocument上に積み重なります。初期開発では便利でも、数年後には影響範囲を把握しにくい構造になりかねません。

現在は、サーバーレンダリング、CDN、ブラウザキャッシュ、事前取得の仕組みが発達し、MPA(Multi-Page Application)の弱点だったページ遷移の待ち時間を抑えやすくなりました。SPAを捨てるという話ではなく、すべてをSPAへ寄せる必要があるのかを見直せる状況になっています。

SPA一択が見直される背景

SPAでは、最初に読み込んだDocumentを維持したまま、JavaScriptがURLと表示内容を切り替えます。ページ間で状態を保持しやすく、編集途中のデータや複雑なUIを連続して扱えることが大きな利点です。

ただし、状態を保持できることと、保持すべきことは別です。記事一覧から記事詳細へ移動するだけのサイトでも、サイト全体をSPAにすると、次の責務をクライアント側で扱うことになります。

  • ルート定義と画面単位のコード分割
  • ログイン状態に応じた表示制御
  • ページ遷移中のローディング表示
  • 画面をまたぐキャッシュと状態の破棄
  • 戻る・進む操作やスクロール位置の復元
  • エラー時の再試行と遷移の整合性

これらが必要なアプリケーションもあります。しかし、各ページが独立して成立するサイトでは、ブラウザとHTTPがすでに持っているナビゲーション機能をJavaScriptで再構築している場合があります。

長寿命のDocumentが生む管理コスト

SPAの運用で問題になりやすいのは、単にJavaScriptの容量が大きいことではありません。一つのDocumentとJavaScript実行環境が長く生き続けることです。

SPAとMPAの状態境界の比較

SPAでは状態が一つのDocumentへ蓄積しやすく、MPAではナビゲーションが境界になります。

たとえば、画面Aでwindowへ登録したイベントリスナーを解除し忘れると、画面Bへ移動した後も処理が残る可能性があります。タイマー、購読処理、WebSocket、グローバルストア、インメモリキャッシュも同様です。個別の不具合は小さくても、長時間利用するほど積み重なり、メモリ使用量の増加や予期しない状態更新につながります。

MPAでは通常、別ページへのナビゲーションで新しいDocumentが作られるため、ページ単位の境界を設けやすくなります。ただし、「遷移した瞬間に以前のページが必ず破棄される」と考えるのは正確ではありません。ブラウザのbfcache(Back/Forward Cache)が使われる場合、以前のページはJavaScriptの実行を停止した状態で保持され、戻る操作で復元されます。

それでも、ページごとに状態の所有範囲を分けられる点は変わりません。SPAでは開発者が破棄のタイミングを設計する必要があり、MPAではDocumentの境界を利用できます。

クライアントルーターが増やす責務

SPAのルーティングは、URLを変えるだけでは終わりません。画面コンポーネントの遅延読み込み、遷移中の表示、エラー処理、ログイン状態に応じた分岐まで含めると、単純なページ遷移でもコードが増えます。

import { lazy, Suspense } from "react";
import {
  BrowserRouter,
  Navigate,
  Route,
  Routes,
} from "react-router-dom";

const Dashboard = lazy(() => import("./pages/Dashboard"));

export function AppRouter() {
  const signedIn = useSessionStatus();

  return (
    <BrowserRouter>
      <Suspense fallback={<LoadingSpinner />}>
        <Routes>
          <Route
            path="/dashboard"
            element={
              signedIn
                ? <Dashboard />
                : <Navigate to="/login" replace />
            }
          />
        </Routes>
      </Suspense>
    </BrowserRouter>
  );
}

このコード自体が間違っているわけではありません。問題は、画面ごとに同じ種類の分岐が増えたときに、クライアント側のルート定義がアプリケーション全体の制御層になりやすいことです。

また、この例のNavigateは表示上のガードにすぎません。URLを直接開いたり、APIを直接呼び出したりする操作を防げるわけではないため、データへのアクセス権限はサーバー側で必ず検証する必要があります。

認可を置くべき境界

SPAでもMPAでも、認可の最終判断はサーバーが担当します。違いは、ページを表示する前の判断をどこまでサーバーへ戻すかです。

SPAとサーバールーティングにおける認可の流れ

クライアント側のルートガードは表示制御であり、APIやページの認可を代替しません。

サーバールーティングでは、リクエストを受けた時点でセッションを検証し、HTMLを返すかログイン画面へリダイレクトするかを決められます。

export async function handleDashboard(
  request: Request,
): Promise<Response> {
  const session = await readSessionFromHttpOnlyCookie(request);

  if (!session) {
    return Response.redirect(
      new URL("/login", request.url),
      302,
    );
  }

  const data = await fetchDashboardData(session.userId);

  return renderPage("dashboard", { data });
}

セッションCookieには用途に応じてHttpOnlySecureSameSiteなどを設定し、ブラウザのJavaScriptから認証情報を直接扱わない設計を検討します。もちろん、Cookieを使えば自動的に安全になるわけではなく、CSRF対策、セッション失効、権限確認は別途必要です。

ページ生成とデータ取得を同じサーバー境界へ置くと、認可前の画面を一度表示してからリダイレクトする流れを避けやすくなります。一方で、APIを分離したSPAでも、BFFやサーバーミドルウェアで同じ原則を適用できます。

現代のMPAが以前より速く感じられる理由

MPAの欠点として、リンクを押すたびに画面全体が白くなり、次のHTMLを待つ体験が挙げられてきました。この問題がなくなったわけではありませんが、現在は待ち時間を減らす手段が増えています。

接続と配信経路

HTTP/2やHTTP/3では、接続を再利用しながら複数のリクエストを処理できます。CDNやエッジキャッシュからHTMLを返せる構成なら、オリジンサーバーまで毎回到達する必要もありません。

ただし、MPAなら自動的に高速になるわけではありません。サーバーのTTFBが長い、キャッシュできない、CSSやフォントが重い、各ページで同じJavaScriptを大量に実行する、といった構成では遷移は遅くなります。

bfcache

bfcacheに保存できるページは、戻る・進む操作でDocumentを再生成せずに復元されます。ユーザーが一覧から詳細へ移動し、すぐ一覧へ戻るような操作では、MPAでも状態とスクロール位置を保った高速な復帰が期待できます。

ただし、すべてのページが必ずbfcacheへ入るわけではありません。利用可否はブラウザやページの実装に左右されるため、pageshowなどを使った復元時の動作確認が必要です。

Speculation Rules API

対応ブラウザでは、Speculation Rules APIを使って、次に開かれそうなDocumentをprefetchまたはprerenderできます。適切に使えば、通常のページ遷移でも待ち時間を短くできます。

一方、事前取得には通信量とサーバー負荷が伴います。ログアウトや購入確定のように状態を変更するURLをGETで実装し、無差別にprefetchする設計は避けなければなりません。導入時は対象URLを絞り、効果とコストを計測する必要があります。

MPAとSPAの間にあるアイランド構成

現在の選択肢は、サイト全体をMPAかSPAのどちらかに統一する二択ではありません。ページはサーバーまたはビルド時にHTMLとして生成し、操作が必要な部分だけJavaScriptを読み込むアイランド構成があります。

Astroでは、通常のコンポーネントはHTMLとして出力され、client:*ディレクティブを付けたUIコンポーネントだけがクライアントで動作します。

---
import ProductSummary from "../components/ProductSummary.astro";
import AddToCart from "../components/AddToCart.tsx";
---

<ProductSummary />
<AddToCart client:visible />

この例では、商品説明は静的なHTMLとして表示し、「カートへ追加」だけをインタラクティブな領域として扱います。サイト全体のルーターや状態管理を読み込まず、必要な操作へ限定してJavaScriptを配布できます。

反対に、複数画面をまたいで編集中のデータを保持する必要があるなら、SPAやクライアントルーターの方が自然です。重要なのは、どちらのラベルを選ぶかではなく、状態と処理をどの境界まで長生きさせるかです。

SPA・MPA・ハイブリッドの選択基準

構成を決めるときは、ページをまたいで保持する状態の広さと、画面内インタラクションの頻度を分けて考えると整理しやすくなります。

SPA・MPA・ハイブリッドの選択基準

判断に迷う場合は、サーバーHTMLを基礎に、必要な部分だけアイランドとして追加する方法から始められます。

SPAが向く場面

デザインツール、表計算、ホワイトボード、音声・映像編集など、操作のたびに大きな状態を更新し続けるアプリケーションでは、Documentを維持する利点が大きくなります。オフライン編集、Undo/Redo、リアルタイム共同編集も、クライアントに長寿命の状態を置く合理的な理由になります。

MPAが向く場面

ブログ、メディア、ドキュメント、商品詳細、検索結果など、URLごとに独立した内容が成立するサイトでは、ページ単位の配信が扱いやすい構成です。JavaScriptを必要としない閲覧体験を先に作り、必要な機能だけ追加できます。

ハイブリッドが向く場面

ECサイトや管理画面では、一覧・詳細・設定画面はページ単位で分けつつ、検索候補、フォーム補助、チャート、カートなどにクライアント処理を使う構成が考えられます。管理画面だからSPA、ECだからMPAと決めるのではなく、操作単位で境界を選びます。

MPAへ戻せば解決するわけではない

MPAを採用しても、複雑さが消えるわけではありません。ルーティングとレンダリングをサーバーへ移せば、今度は次の設計が重要になります。

  • サーバー処理とデータベースアクセスの遅延
  • HTMLとデータのキャッシュ方針
  • セッション管理とCSRF対策
  • フォーム送信後の再表示や二重送信対策
  • ページ間で本当に共有すべき状態の保存先
  • サーバーエラー時のフォールバックと監視

SPAからMPAへ全面移行する前に、まず計測すべきなのは、起動時間、画面遷移、メモリ使用量、操作応答、サーバーTTFBのどこが問題なのかです。ボトルネックがクライアント状態の長寿命化にないなら、構成変更より局所的な改善の方が効果的な場合があります。

まとめ

SPAは、複雑な状態を途切れさせずに扱うための有力な構成です。しかし、その利点を必要としないページまで一つのJavaScriptアプリケーションへ含めると、ルーティング、破棄処理、認証状態、キャッシュ管理の責務が増えます。

現代のMPAは、CDN、bfcache、事前取得、アイランド構成を組み合わせることで、従来より滑らかな体験を提供しやすくなりました。それでも、MPAがSPAより常に速い、または簡単という結論にはなりません。

構成を選ぶ前に確認したいのは、次の三点です。

  1. ページをまたいで保持しなければならない状態は何か
  2. 画面内で継続的に更新される操作はどれか
  3. その状態と処理をブラウザで長生きさせる必要があるか

状態管理の仕組みを増やす前に、その状態をページ境界で終わらせられないかを考えることが、SPA一択を見直す出発点になります。

参考資料