読み込み中...
この記事は、ひとりでつくるSaaS - 設計・実装・運用の記録 Advent Calendar 2025 の11日目の記事です。
昨日の記事では「App Routerのディレクトリ設計」について書きました。この記事では、MPAからSPAへの移行を実践した理由と具体的な実装について解説します。
App Routerを採用した当初は、Server Componentsの恩恵を最大化するため、MPA的な構成にしていました。ページ遷移のたびにサーバーでHTMLを生成し、新しいページを表示する方式です。
しかし、開発を進めるうちに以下の課題が見えてきました。
1. ナビゲーションメニューの再読み込み
サイドバーにナビゲーションメニューを配置していますが、ページ遷移のたびに再読み込みされていました。展開状態がリセットされたり、一瞬ちらついたりして、体験が損なわれていました。
2. スクロール位置のリセット
一覧画面でスクロールして詳細画面に遷移し、戻ると最初の位置に戻ってしまう。フィルタ条件もリセットされ、再度設定し直す必要がありました。
3. 遷移時のちらつき
ページ遷移のたびに画面全体が再描画されるため、レイアウトが一瞬崩れたり、ローディング状態が目立ったりしていました。
詳細画面から一覧に戻ったとき、元のスクロール位置を復元します。
// 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 };
}フィルタやソート条件を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は、シンプルで軽量な状態管理ライブラリです。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パラメータを単一情報源(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化で重要なのは、レイアウト層でのデータ取得を避けることです。
// ❌ ページ遷移のたびにデータを再取得してしまう
function MainLayout({ children }: { children: ReactNode }) {
const { data, isLoading } = useArticles(); // ここでデータ取得
return (
<div className="flex">
<Sidebar articles={data} isLoading={isLoading} />
<main>{children}</main>
</div>
);
}// ✅ レイアウト層では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を変更し、必要なコンポーネントだけを更新できます。
最初から完全なSPAを目指すのではなく、課題が顕著な画面から段階的に移行しました。
SPA化しても、初回表示はSSRで行っています。App RouterのServer Componentsを活かし、初回表示は高速に、遷移後はクライアントサイドで処理しています。
スクロール位置、フィルタ条件、メニュー展開状態など、復元したい状態は適切に永続化します。
| 状態 | 保存先 | 理由 |
|---|---|---|
| フィルタ条件 | URL | シェア可能、履歴連携 |
| スクロール位置 | sessionStorage | タブ内で復元 |
| メニュー展開状態 | localStorage | ユーザー設定として永続化 |
| 表示形式 | localStorage | ユーザー設定として永続化 |
MPAからSPAへの移行で実現したことをまとめます。
解決した課題:
設計のポイント:
App Routerを使いながらSPA的な体験を実現することで、Server Componentsの恩恵とクライアントサイドの快適さを両立できました。
明日は「Next.js Route HandlerからHonoへ」について解説します。
シリーズの他の記事
コメント