0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

なぜMPAからSPAに移行したのか:App Routerリファクタリング実践

Last updated at Posted at 2025-12-11

この記事は、ひとりでつくるSaaS - 設計・実装・運用の記録 Advent Calendar 2025 の11日目の記事です。

昨日の記事では「App Routerのディレクトリ設計」について書きました。この記事では、MPAからSPAへの移行を実践した理由と具体的な実装について解説します。

📝 この記事で使う用語

  • MPA(Multi Page Application): ページ遷移のたびにサーバーからHTMLを取得し、画面全体を再読み込みする方式
  • SPA(Single Page Application): 初回読み込み後はJavaScriptでページを切り替え、画面全体を再読み込みしない方式
  • クライアントサイドナビゲーション: ブラウザ側でURLを変更し、必要なデータだけを取得してページを更新する方式

🎯 なぜSPAに移行したのか

App Routerを採用した当初は、Server Componentsの恩恵を最大化するため、MPA的な構成にしていました。ページ遷移のたびにサーバーでHTMLを生成し、新しいページを表示する方式です。

しかし、開発を進めるうちに以下の課題が見えてきました。

MPA的な構成の課題

1. ナビゲーションメニューの再読み込み

サイドバーにナビゲーションメニューを配置していますが、ページ遷移のたびに再読み込みされていました。展開状態がリセットされたり、一瞬ちらついたりして、体験が損なわれていました。

2. スクロール位置のリセット

一覧画面でスクロールして詳細画面に遷移し、戻ると最初の位置に戻ってしまう。フィルタ条件もリセットされ、再度設定し直す必要がありました。

3. 遷移時のちらつき

ページ遷移のたびに画面全体が再描画されるため、レイアウトが一瞬崩れたり、ローディング状態が目立ったりしていました。

🔧 SPA移行で実現したこと

1. スクロール位置の復元

詳細画面から一覧に戻ったとき、元のスクロール位置を復元します。

// useScrollRestoration.ts
const SCROLL_CACHE_KEY = 'app_scroll_cache';
const CACHE_EXPIRY = 5 * 60 * 1000; // 5分

export function useScrollRestoration() {
  // スクロール位置を保存
  const saveScroll = useCallback(() => {
    const cache = {
      scrollY: window.scrollY,
      pathname: window.location.pathname,
      timestamp: Date.now(),
    };
    sessionStorage.setItem(SCROLL_CACHE_KEY, JSON.stringify(cache));
  }, []);

  // スクロール位置を復元
  const restoreScroll = useCallback(() => {
    const stored = sessionStorage.getItem(SCROLL_CACHE_KEY);
    if (!stored) return;

    const cache = JSON.parse(stored);

    // 有効期限チェック
    if (Date.now() - cache.timestamp > CACHE_EXPIRY) {
      sessionStorage.removeItem(SCROLL_CACHE_KEY);
      return;
    }

    // 同じパスなら復元
    if (cache.pathname === window.location.pathname) {
      window.scrollTo(0, cache.scrollY);
    }
  }, []);

  return { saveScroll, restoreScroll };
}

2. フィルタ状態のURL同期

フィルタやソート条件をURLパラメータに保存し、ブラウザの履歴と連携させています。ここではnuqsというライブラリを使っています。nuqsは、URLパラメータをReactの状態として扱えるライブラリです。

// useListFilters.ts
import { parseAsString, parseAsStringEnum, useQueryStates } from 'nuqs';

export const listFilterParsers = {
  category: parseAsString,
  tag: parseAsString,
  sort: parseAsStringEnum(['newest', 'oldest', 'popular'] as const)
    .withDefault('newest'),
  search: parseAsString,
};

export function useListFilters() {
  return useQueryStates(listFilterParsers, {
    history: 'push',   // ブラウザ履歴に追加
    shallow: true,     // サーバー再取得なし
  });
}

これにより、以下のようなURLが生成されます。

/articles?category=tech&sort=popular&search=Next.js

URLをコピーしてシェアすれば、同じフィルタ状態で一覧を表示できます。

📦 状態管理の設計

SPA化にあたり、状態管理を整理しました。

Zustandでグローバル状態を管理

Zustandは、シンプルで軽量な状態管理ライブラリです。Reduxより設定が少なく、Providerでラップする必要もないため、手軽に導入できます。

一覧データやローディング状態をZustandで一元管理しています。

// articleStore.ts
import { create } from 'zustand';

interface Article {
  id: string;
  title: string;
  category: string;
  createdAt: string;
}

interface ArticleStore {
  // 記事一覧
  articles: Article[];
  setArticles: (articles: Article[]) => void;

  // ローディング状態
  isLoading: boolean;
  setIsLoading: (loading: boolean) => void;
}

export const useArticleStore = create<ArticleStore>(set => ({
  articles: [],
  setArticles: (articles) => set({ articles }),
  isLoading: false,
  setIsLoading: (isLoading) => set({ isLoading }),
}));

URLを単一情報源として活用

フィルタ条件はURLパラメータを単一情報源(Single Source of Truth)としています。

// FilterContext.tsx
export function FilterProvider({ children }: { children: ReactNode }) {
  // URLからフィルタ状態を取得(nuqs)
  const [filters, setFilters] = useListFilters();

  // 派生状態はURLから計算
  const hasActiveFilters = useMemo(() => {
    return !!(filters.category || filters.tag || filters.search);
  }, [filters]);

  return (
    <FilterContext.Provider value={{ filters, setFilters, hasActiveFilters }}>
      {children}
    </FilterContext.Provider>
  );
}

🖥️ レイアウト層の工夫

SPA化で重要なのは、レイアウト層でのデータ取得を避けることです。

Before: レイアウトでデータ取得

// ❌ ページ遷移のたびにデータを再取得してしまう
function MainLayout({ children }: { children: ReactNode }) {
  const { data, isLoading } = useArticles();  // ここでデータ取得

  return (
    <div className="flex">
      <Sidebar articles={data} isLoading={isLoading} />
      <main>{children}</main>
    </div>
  );
}

After: レイアウトはストアを参照するだけ

// ✅ レイアウト層ではZustandの状態を参照するだけ
function MainLayout({ children }: { children: ReactNode }) {
  // Zustandから状態を取得(データ取得はしない)
  const articles = useArticleStore(state => state.articles);
  const isLoading = useArticleStore(state => state.isLoading);

  return (
    <div className="flex">
      <Sidebar articles={articles} isLoading={isLoading} />
      <main>{children}</main>
    </div>
  );
}

データ取得は各ページコンポーネントで行い、結果をZustandに保存します。レイアウト層はその状態を参照するだけなので、ページ遷移時に再取得が発生しません。

🔀 クライアントサイドナビゲーション

Next.jsのuseRouterを使ってクライアントサイドナビゲーションを実装しています。

// useSPANavigation.ts
import { useRouter } from 'next/navigation';
import { useCallback } from 'react';

export const useSPANavigation = () => {
  const router = useRouter();

  const navigateTo = useCallback((path: string) => {
    router.push(path);
  }, [router]);

  const navigateBack = useCallback(() => {
    router.back();
  }, [router]);

  return { navigateTo, navigateBack };
};

router.push()を使うことで、ページ全体を再読み込みせずにURLを変更し、必要なコンポーネントだけを更新できます。

🎯 移行のポイント

1. 段階的に移行する

最初から完全なSPAを目指すのではなく、課題が顕著な画面から段階的に移行しました。

  • 第1段階: ナビゲーションメニューの状態保持
  • 第2段階: 一覧↔詳細のスクロール復元
  • 第3段階: フィルタ条件のURL同期

2. SSRの恩恵は維持する

SPA化しても、初回表示はSSRで行っています。App RouterのServer Componentsを活かし、初回表示は高速に、遷移後はクライアントサイドで処理しています。

3. 永続化を意識する

スクロール位置、フィルタ条件、メニュー展開状態など、復元したい状態は適切に永続化します。

状態 保存先 理由
フィルタ条件 URL シェア可能、履歴連携
スクロール位置 sessionStorage タブ内で復元
メニュー展開状態 localStorage ユーザー設定として永続化
表示形式 localStorage ユーザー設定として永続化

✅ まとめ

MPAからSPAへの移行で実現したことをまとめます。

解決した課題:

  • ナビゲーションメニューの再読み込み → Zustandで状態を保持
  • スクロール位置のリセット → sessionStorageで復元
  • フィルタ条件のリセット → URLパラメータで永続化
  • 画面遷移時のちらつき → クライアントサイドナビゲーション

設計のポイント:

  • Zustandでグローバル状態を管理
  • URLを単一情報源として活用
  • レイアウト層でのデータ取得を避ける
  • SSRの恩恵は維持する

App Routerを使いながらSPA的な体験を実現することで、Server Componentsの恩恵とクライアントサイドの快適さを両立できました。

明日は「Next.js Route HandlerからHonoへ」について解説します。


シリーズの他の記事

  • 12/10: App Routerのディレクトリ設計:Next.jsプロジェクトの構成術
  • 12/12: Next.js Route HandlerからHonoへ:API設計が楽になった理由
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?