React+Next.jsでDummy JSONのCRUDをCSR/SSRの両方で作成して違いを調べてみた話

Next.jsでCRUDを作りSSRの挙動を調べてみた。いったんPage Routerを選択。
バックエンドとなるAPIとしてDummyJSONのPosts-Docs APIを使用した。
一覧、詳細、更新、削除が用意される。ただし更新、削除はダミーで永続化されない。

目次は以下。

構成

CSR版/SSR版の2パターンについてCRUDを行うアプリをClaude(Sonnet4.5)で環境を構築した。
ルーティングについては今回の調査範囲外のため、いったんシンプルなPage Routerを使用した。
npm run dev で next dev –turbopack が動く何かが作られた。turbopackはrust製のwebpackの後継。
いったん実行環境の詳細な把握をスキップして上物の理解を進めることにする。上物の構成は以下。


.
├── app/
│   ├── page.tsx                    # ホームページ
│   └── posts/
│       ├── page.tsx                # 投稿一覧選択(CSR/SSR)
│       ├── csr/
│       │   ├── page.tsx            # 投稿一覧(Client Component版)
│       │   ├── [id]/
│       │   │   ├── page.tsx        # 投稿詳細(Client Component版)
│       │   │   └── edit/
│       │   │       └── page.tsx    # 投稿編集(Client Component版)
│       │   └── new/
│       │       └── page.tsx        # 新規投稿作成(Client Component版)
│       ├── ssr/
│       │   ├── page.tsx            # 投稿一覧(Server Component版)
│       │   ├── [id]/
│       │   │   ├── page.tsx        # 投稿詳細(Server Component版)
│       │   │   └── edit/
│       │   │       └── page.tsx    # 投稿編集(Server Actions版)
│       │   └── new/
│       │       └── page.tsx        # 新規投稿作成(Server Actions版)
│       └── _components/
│           └── DeleteButton.tsx    # 削除ボタン(Client Component)
├── lib/
│   └── api.ts                      # API関数
├── types/
│   └── post.ts                     # 型定義
├── Dockerfile                      # Dockerイメージ設定
├── docker-compose.yml              # Docker Compose設定
└── next.config.ts                  # Next.js設定

以下のようなアプリができた。

ReactとNext.jsの関係性と役割分担

ReactはUIを作るためのJavaScriptライブラリとして機能する。コンポーネント、フック、JSXを提供する。
Next.jsはReactを使ったフレームワークであり、ルーティング、ビルド、最適化などの機能を提供する。

React (ライブラリ) Next.js (フレームワーク)
役割の焦点 UI (ユーザーインターフェース) 構築 Webアプリケーション全体の構築
主な提供物 コンポーネント (UI要素)、JSX、Hooks (状態管理など) ルーティング、レンダリング戦略、最適化機能、バックエンド連携
ルーティング 非搭載。別途React Routerなどのライブラリが必要。 ファイルベースルーティングが組み込みで提供される。
レンダリング クライアントサイドレンダリング (CSR) が基本。ブラウザでJavaScriptが実行されてUIを描画する。 プリレンダリング (SSR/SSG) が組み込みで提供される。レンディングのタイミングと場所を制御する。
データ取得 非搭載。fetch APIなどをコンポーネント内で使用する。 データ取得パターン (Server Components, Route Handlersなど) とキャッシュの仕組みが組み込みで提供される。

クライアントサイドレンダリング(CSR)

ブラウザ側で動的にHTMLを生成する。useState、useEffect、イベントハンドラが使える。

Step1.初期レンダリング(サーバ側)
  • app/layout.tsx がサーバーで実行される。<html><body>の枠組みを作る。
  • app/posts/[id]/page.tsxがClinet Componentとして認識され初期HTMLを作る。
  • ブラウザに初期HTMLを送り、ブラウザは初期HTMLを表示する

  // app/layout.tsx (20-34行目)
  export default function RootLayout({ children }: { children: React.ReactNode }) {
    return (
      <html lang="en">
        <body>
          {children}  {/* ← ここに子コンポーネントが入る */}
        </body>
      </html>
    );
  }
  ...

  //ブラウザに送られる初期HTMLの例
  <html>
    <body>
      <div class="min-h-screen p-8">
        <p>読み込み中...</p>  ← loading=trueの状態
      </div>
      <script src="/_next/..."></script>  ← クライアント用JS
    </body>
   </html>

Step2.ハイドレーション(Hydration)
  • JavaScriptが読み込まれる
  • Reactがコンポーネントを「水分補給」(Hydrate)、HTMLに機能を追加
  • 初期state: loading = true, post = null
Step3.useEffectの実行(副作用)

コンポーネントが画面に表示された直後に1回実行される。


  // app/posts/[id]/page.tsx (16-18行目)
  useEffect(() => {
    loadPost();  // ← コンポーネントがマウントされたら実行
  }, [params.id]);

Step4.データフェッチとstate更新
  • api.getPost()を実行。-> fetch(‘https://dummyjson.com/posts/1’)
  • const data でレスポンスを受け取る
  • setPost(data)でstateを更新
  • setLoading(false)でローディング終了。loading=false

  // app/posts/[id]/page.tsx (20-32行目)
  const loadPost = async () => {
    try {
      setLoading(true);  // ローディング表示
      const data = await api.getPost(Number(params.id));  // API呼び出し
      setPost(data);  // ← state更新!
      setError(null);
    } catch (err) {
      setError('投稿の読み込みに失敗しました');
    } finally {
      setLoading(false);  // ← state更新!
    }
  };


Step5.再レンダリング(stateが変わったので)
  • post stateが更新されたので再レンダリング
  • 条件分岐を再評価
  • 最終的なJSXをDOMに反映

 // app/posts/[id]/page.tsx (47-55行目)
  if (loading) {  // loading = false なので通過
    return 
読み込み中...
; } if (error || !post) { // error = null, post = データあり なので通過 return
エラー表示
; } // ここが実行される! return ( <div className="min-h-screen p-8"> <h1>{post.title}</h1> {/* ← post.title を表示 */} <p>{post.body}</p> {/* ← post.body を表示 */} {/* ... */} </div> );
Step6.リストの動的レンダリング

  // app/posts/[id]/page.tsx (133-140行目)
  {post.tags.map((tag, index) => (  // ← 配列をループ
    <span key={index} className="...">
      {tag}  {/* ← 各タグを表示 */}
    </span>
  ))}

  実行結果:
  post.tags = ["history", "american", "crime"]

  ↓ map() で変換

  <span key={0}>history</span>
  <span key={1}>american</span>
  <span key={2}>crime</span>

全体のレンダリングフロー

  [ユーザーが /posts/1 にアクセス]
             ↓
  ┌──────────────────────────────┐
  │  サーバー側(Next.js Server)   │
  ├──────────────────────────────┤
  │ 1. app/layout.tsx を実行      │
  │    → を生成       │
  │                              │
  │ 2. app/posts/[id]/page.tsx   │
  │    を「クライアントコンポーネント」│
  │    として認識                 │
  │    → 初期HTML生成             │
  │      (loading=true状態)      │
  └──────────────────────────────┘
             ↓ HTML + JS送信
  ┌──────────────────────────────┐
  │  ブラウザ側(Client)          │
  ├──────────────────────────────┤
  │ 3. HTMLを表示                │
  │    「読み込み中...」          │
  │                              │
  │ 4. JavaScriptロード          │
  │    → Hydration実行           │
  │                              │
  │ 5. useEffect発火             │
  │    → loadPost()実行          │
  │                              │
  │ 6. API呼び出し               │
  │    fetch(https://dummyjson.com/posts/1)
  │           ↓                  │
  │    レスポンス受信             │
  │                              │
  │ 7. setState実行              │
  │    setPost(data)             │
  │    setLoading(false)         │
  │           ↓                  │
  │ 8. 再レンダリング             │
  │    → 投稿詳細を表示           │
  └──────────────────────────────┘

サーバーサイドレンダリング(SSR)

サーバ側でHTMLが生成される。DBやAPIに直接アクセスできる。useState,useEffectを使わない。
例えば、当アプリにおいて / へのアクセスに対してNext.jsが app/page.tsx を実行する。
HTMLを生成してブラウザに送信し、ブラウザはHTMLを表示する。

1周回って戻ってきたというか、LaravelやRailsにフロントエンドから寄っていくスタイル。
バックエンドにAPIを用意せずDBを直接操作できるため、SPAが不要な簡易的な管理画面など、
大幅な工数削減が可能になると思う。

Laravel,Railsだと、フロントエンドの記述にVue/Reactを導入する必要があるため、
バックエンド・フロントエンド、という棲み分けが発生してしまうが、
Next.jsのSSR(+CSR混在)により、フロントエンドとバックエンドを同じ仕組みで実現できる点で
管理する対象が大幅に減るのもメリットだと思う。


import Link from 'next/link';
import { api } from '@/lib/api';
import DeleteButton from '@/app/posts/_components/DeleteButton';

// Server Component(デフォルト)
// 'use client' ディレクティブがないため、サーバー側で実行される
export default async function PostsPageSSR() {
  // サーバー側で直接データ取得
  // useEffect や useState は不要
  const data = await api.getPosts();
  const posts = data.posts;

  return (
    <div className="min-h-screen p-8 bg-gray-50">
      <div className="max-w-4xl mx-auto">
        {/* ヘッダー部分 */}
        <div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
          <p className="text-sm text-blue-800">
            <strong>Server Component版 - このページはサーバー側でレンダリングされ、HTMLに既にデータが含まれています
          </p>
        </div>
        ...
  );
}

SSRとCSRの統合

SSRモードとCSRモードの2つのモードが存在する訳ではなく、SSRとCSRは同時に存在し得る。
例えば、今回作成したSSR版アプリの投稿一覧画面において、CSRで削除ボタンを実現している。
コンポーネント単位でSSR/CSRの分離が起こるだけで、アーキ全体ではSSRとCSRは混在できる。
TypeScriptにより型安全にpropsを渡せるし、状態管理がReactの仕組みで統一できる。
部分的な更新は可能 (router.refresh() )。


  // app/posts/ssr/page.tsx (Server Component)
  export default async function PostsPageSSR() {
    const data = await api.getPosts(); // サーバー側で実行
    const posts = data.posts;

    return (
      <div>
        {posts.map(post => (
          <div key={post.id}>
            <h2>{post.title}</h2>
            {/* Client Componentをそのまま埋め込める */}
            <DeleteButton postId={post.id} />
          </div>
        ))}
      </div>
    );
  }

  // app/posts/_components/DeleteButton.tsx (Client Component)
  'use client';

  export default function DeleteButton({ postId }: { postId: number }) {
    const router = useRouter();

    const handleDelete = async () => {
      if (!confirm('削除しますか?')) return;
      await api.deletePost(postId);
      router.refresh(); // この部分だけ更新
    };

    return (
      <button onClick={handleDelete}>削除</button>
    );
  }


まとめ

Next.jsのHello WorldをしつつSSRとCSRの挙動を確認した。
フロント側フレームワークの枠組みを越え、フロント・バックエンドを統一的に扱えることを確認した。
アプリケーションの要件次第で、SSRを中心に部分的にCSRとすることで大幅な工数削減を期待できそう。