SSRとCSRを組み合わせたレンダリングの設計について考えた
Published on April 20, 2025
Category: Next.js
前書き
レンダリングに関する設計を考えました。
初期レンダリングは基本的にSSRで実装することを考えています。
その中で、インタラクティブな状態を管理するためにCSRを導入する必要がありますが、SSRで状態管理ができないことから、どのように設計することで実装を仕組みかすることができるかを考えてみました。
本業、個人開発でも実際にぶつかった課題をもとに記事を作成しております。
また、コードやディレクトリ構成はサンプルであるため、改善する箇所はたくさんあるかと思います。
この記事ではどのように管理するべきか、設計の考え方部分にフォーカスしているものでありで、実際のプロダクトで使用している命名規則などではありませんので、ご了承ください。
現状
SSRのpage.tsx上でCSRのコンポーネントを参照し、custom hooksを用いて状態管理を行おうとしている
下記のようなエラーが出力されている
[ Server ] Error: Attempted to call useRegisterPlayer() from the server but useRegisterPlayer is on the client. It's not possible to invoke a client function from the server, it can only be rendered as a Component or passed to props of a Client Component.
やりたいこと
page.tsx自体はSSRで実装しつつ、CSRのコンポーネントをpage.tsx上に参照して、状態管理を実現したい。
制約
apps/
└── beatfolio/
├── app/player/create
└── page.tsx
packages/
└── ui/
└── components/
└── players/
├── playerCard.tsx
├── playerForm.tsx
├── playersList.tsx
└── index.ts
- 表示するページの初期レンダリング
- SSRを用いて、初期レンダリングを行います。
- pacages/ui/cmomponete/index.tsx
- UIに関する責務を全うする
- APIを実行するためのHooksなどはここに含めない
現在抱えている課題
1. サーバーとクライアントの境界線問題
最大の課題は、サーバーサイドレンダリング(SSR)環境と、クライアントサイド専用のフック(useRegisterPlayer()
)との間に存在する「実行環境の不一致」
具体的な技術的制約
- サーバーサイドは静的な初期データを生成
- クライアントサイドフックは、ブラウザ環境でのみ実行可能
- サーバーコンポーネントは、クライアントサイドフックを直接呼び出せない
2. データフロー
初期データをサーバーサイドで取得し、クライアントサイドで管理する流れは下記です
- サーバーサイドでの初期データ取得
- クライアントサイドへのデータ受け渡し
- クライアントサイドでの状態管理
- インタラクティブな更新処理の実現
解消するための設計
下記のフローを実現できれば、SSRとCSRを共存させて実装の仕組みかをすることが可能

ディレクトリ構成
apps/
└── beatfolio/
├── app/
│ ├── players/
│ └── page.tsx
│
├── components/
│ └── providers/
│ └── client-wrappers/
│ └── PlaterWrapper.tsx
├── features/
│ └── players/
│ └── create/
│ ├── context/
│ │ └── index.ts
│ ├── hooks/
│ │ └── index.ts
│ └── fetchers/
│ └── index.ts
└── types/
└── player.ts
packages/
└── ui/
└── components/
└── players/
└── PlayerCreateForm.tsx
実装の詳細
1. beatfolio/players/page.tsx (サーバーコンポーネント)
役割:
- サーバーサイドでのデータ取得
- 初期HTMLレンダリング
- クライアント境界コンポーネントへのデータ受け渡し
// app/players/page.tsx
import PlayersWrapper from '@/components/providers/clientWrappers/PlayersWrapper';
import { Players } from '@ui/components/Players';
import { getPlayers } from '@/features/players/fetchers';
export default async function PlayersPage() {
// サーバーサイドでデータ取得
const initialPlayers = await getPlayers();
return (
<div className="container mx-auto p-6">
<h1 className="text-2xl font-bold mb-6">スライド管理</h1>
{/* サーバー/クライアント境界 */}
<PlayersWrapper initialData={initialPlayers}>
<PlayerList />
</PlayersWrapper>
</div>
);
}
2. components/providers/client-wrappers/playersWrapper.tsx (クライアント境界)
役割:
- サーバー/クライアント境界点
- クライアントサイドフックの初期化
- コンテキストプロバイダーの提供
- サーバーからの初期データの取り込み
// components/providers/client-wrappers/PlayersWrapper.tsx
'use client';// クライアントサイドマーカー
import { ReactNode } from 'react';
import { PlayerContext } from '@/features/players/contexts';
import { useRegisterPlayer } from '@/features/players/hooks';
import { Player } from '@/features/players/types';
interface PlayersWrapperProps {
children: ReactNode;
initialData?: Player[];
}
export default function PlayersWrapper({
children,
initialData = []
}: PlayersWrapperProps) {
// クライアントサイドフックを初期化
const playerState = useRegisterPlayer(initialData);
return (
<PlayerContext.Provider value={playerState}>
{children}
</PlayerContext.Provider>
);
}
3. features/players/contexts/player-context.tsx (コンテキスト)
役割:
- コンポーネント間のデータ共有
- 状態とアクションの型定義
- Reactコンテキスト作成
// features/players/contexts/player-context.tsx
'use client';
import { createContext } from 'react';
import { Player } from '../types';
// コンテキスト型
export interface playerContextType {
players: Player[];
registerPlayer: (player: Player) => void;
updatePlayer: (id: number, data: Partial<Player>) => void;
deletePlayer: (id: number) => void;
isLoading: boolean;
error: string | null;
}
// デフォルト値
const defaultContext: playerContextType = {
players: [],
registerPlayer: () => {},
updatePlayer: () => {},
deletePlayer: () => {},
isLoading: false,
error: null
};
// コンテキスト作成
export const PlayerContext = createContext<PlayerContextType>(defaultContext);
4. features/players/contexts/index.ts (エクスポート)
役割:
- 内部モジュールを外部に公開
- import文の簡略化
// features/players/contexts/index.ts
export * from './player-context';
5. features/players/hooks/use-register-player.ts (フック)
役割:
- 状態管理ロジックのカプセル化
- イベントハンドラとデータ操作
- API通信との連携
// features/players/hooks/use-register-player.ts
'use client';
import { useState, useCallback } from 'react';
import { Player } from '../types';
import { createPlayer, updatePlayer, deletePlayer } from '../fetchers';
export function useRegisterPlayer(initialPlayers: Player[] = []) {
const [players, setPlayers] = useState<Player[]>(initialPlayers);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// プレイヤー登録
const registerPlayer = useCallback(async (newPlayer: Omit<Player, 'id'>) => {
try {
setIsLoading(true);
setError(null);
// APIコール
const created = await createPlayer(newPlayer);
setPlayers(prev => [...prev, created]);
return created;
} catch (err) {
const errorMsg = err instanceof Error ? err.message : '登録エラー';
setError(errorMsg);
throw err;
} finally {
setIsLoading(false);
}
}, []);
// プレイヤー更新
const handleUpdatePlayer = useCallback(async (id: number, data: Partial<Player>) => {
try {
setIsLoading(true);
setError(null);
// APIコール
const updated = await updatePlayer(id, data);
setPlayers(prev =>
prev.map(player => player.id === id ? { ...player, ...updated } : player)
);
return updated;
} catch (err) {
const errorMsg = err instanceof Error ? err.message : '更新エラー';
setError(errorMsg);
throw err;
} finally {
setIsLoading(false);
}
}, []);
// プレイヤー削除
const handleDeletePlayer = useCallback(async (id: number) => {
try {
setIsLoading(true);
setError(null);
// APIコール
await deletePlayer(id);
setPlayers(prev => prev.filter(player => player.id !== id));
} catch (err) {
const errorMsg = err instanceof Error ? err.message : '削除エラー';
setError(errorMsg);
throw err;
} finally {
setIsLoading(false);
}
}, []);
return {
players,
registerPlayer,
updatePlayer: handleUpdatePlayer,
deletePlayer: handleDeletePlayer,
isLoading,
error
};
}
6. features/players/hooks/index.ts (エクスポート)
// features/players/hooks/index.ts
export * from './use-register-player';
export * from './use-update-player';// 追加のフックがある場合
7. features/players/fetchers/get-players.ts (サーバーフェッチャー)
役割:
- サーバーサイドでのデータ取得
- APIエンドポイントとの通信
- エラーハンドリング
// features/players/fetchers/get-players.ts
import { Player } from '../types';
export async function getPlayers(): Promise<Player[]> {
try {
// APIエンドポイントからデータ取得
const response = await fetch('<https://api.example.com/players>', {
headers: {
// サーバーサイドのみの環境変数
Authorization: `Bearer ${process.env.API_KEY}`
},
next: { revalidate: 60 }// 60秒キャッシュ
});
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Error fetching players:', error);
return [];// エラー時は空配列
}
}
8. features/players/fetchers/create-player.ts (クライアントフェッチャー)
役割:
- クライアントサイドでのAPI通信
- データ送信処理
- レスポンス処理
// features/players/fetchers/create-player.ts
'use client';
import { Player } from '../types';
export async function createPlayer(playerData: Omit<Player, 'id'>): Promise<Player> {
const response = await fetch('/api/players', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(playerData),
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error(error.message || 'プレイヤーの作成に失敗しました');
}
return await response.json();
}
9. features/players/types/player.ts (型定義)
役割:
- データ構造の型定義
- 型安全性の確保
- インターフェース定義
// features/players/types/player.ts
export interface Player {
id: number;
title: string;
content: string;
createdAt: string;
updatedAt: string;
imageUrl?: string;
status: 'draft' | 'published';
}
export type PlayerCreateInput = Omit<Player, 'id' | 'createdAt' | 'updatedAt'>;
export type PlayerUpdateInput = Partial<PlayerCreateInput>;