【Next.js完全ガイド Vol.3】データフェッチング戦略の完全ガイド

に公開

【Next.js完全ガイド Vol.3】データフェッチング戦略の完全ガイド

はじめに

この記事は「Next.js完全ガイド」シリーズの第3回です。

📚 シリーズ構成

前回のおさらい

前回は、Next.jsの3つのレンダリング戦略を学びました:

  • 静的レンダリング(ビルド時に生成)
  • 動的レンダリング(リクエストごとに生成)
  • ストリーミング(段階的に配信)
  • コンポーネント単位での使い分け方法

この記事で学べること

  • 4つのデータフェッチングパターン
  • Next.js 15のキャッシング動作の重要な変更点
  • Server Actions の使い方と実践例
  • SWRを使った高度なクライアントサイドフェッチング
  • データフェッチング戦略の選び方

1. データフェッチングの基本原則

Next.jsのデータフェッチングは、どこで(サーバー/クライアント)、いつ(ビルド時/リクエスト時)、どのように(キャッシュする/しない)取得するかを柔軟に制御できます。

4つの主要なパターン

┌─────────────────────────────────────────┐
│ 1. Server Components でのデータ取得    │
│    - デフォルトの方法                   │
│    - データソースに近い                 │
│    - セキュア                           │
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ 2. Route Handlers (API Routes)         │
│    - /app/api/...                       │
│    - REST API エンドポイント            │
│    - 外部クライアントとの統合           │
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ 3. Server Actions                       │
│    - フォーム送信                       │
│    - データ変更(mutation)             │
│    - 'use server' ディレクティブ        │
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ 4. Client Components でのフェッチ      │
│    - useEffect + fetch                  │
│    - SWR / React Query                  │
│    - リアルタイムデータ                 │
└─────────────────────────────────────────┘

2. Server Componentsでのデータフェッチング

基本パターン

// app/posts/page.js

// 最もシンプルなパターン
export default async function PostsPage() {
  const posts = await fetch('https://api.example.com/posts')
    .then(res => res.json());
  
  return (
    <ul>
      {posts.map(post => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}

Next.js 15のキャッシング動作

⚠️ 重要な変更点:

Next.js 15から、fetchリクエストはデフォルトでキャッシュされなくなりました

// Next.js 14: 自動的にキャッシュされる
const data = await fetch('https://api.example.com/data');

// Next.js 15: キャッシュされない(毎回新しいデータを取得)
const data = await fetch('https://api.example.com/data');

// Next.js 15: 明示的にキャッシュする
const data = await fetch('https://api.example.com/data', {
  cache: 'force-cache'
});

// 再検証付きキャッシュ(60秒ごとに更新)
const data = await fetch('https://api.example.com/data', {
  next: { revalidate: 60 }
});

パターン別の実装例

パターン1: リアルタイムデータ(キャッシュなし)

// 株価、為替レート、ライブスコアなど
export default async function StockPrice() {
  const price = await fetch('https://api.stocks.com/AAPL', {
    cache: 'no-store' // 明示的にキャッシュを無効化
  }).then(r => r.json());
  
  return <div>現在価格: ${price.current}</div>;
}

パターン2: 時間ベースの再検証(ISR)

// ニュース記事、ブログ投稿など
export default async function NewsPage() {
  const articles = await fetch('https://api.news.com/articles', {
    next: { 
      revalidate: 300 // 5分ごとに再検証
    }
  }).then(r => r.json());
  
  return (
    <div>
      {articles.map(article => (
        <Article key={article.id} {...article} />
      ))}
    </div>
  );
}

動作の流れ:

時刻: 10:00 - ユーザーA訪問
↓
キャッシュなし → API呼び出し → レンダリング → キャッシュ保存

時刻: 10:03 - ユーザーB訪問
↓
キャッシュあり(まだ5分経過していない) → 即座に表示

時刻: 10:06 - ユーザーC訪問
↓
キャッシュ期限切れ → 古いキャッシュ表示
↓
バックグラウンドで再検証開始
↓
次回アクセス時には新しいデータ

パターン3: 完全静的(ビルド時のみ取得)

// FAQページ、会社概要など
export default async function AboutPage() {
  const companyInfo = await fetch('https://api.company.com/info', {
    cache: 'force-cache' // ビルド時に取得し、永続的にキャッシュ
  }).then(r => r.json());
  
  return <div>{companyInfo.description}</div>;
}

パターン4: オンデマンド再検証(タグベース)

// app/products/page.js

export default async function ProductsPage() {
  const products = await fetch('https://api.shop.com/products', {
    next: { 
      tags: ['products'] // タグ付け
    }
  }).then(r => r.json());
  
  return <ProductList products={products} />;
}
// 管理画面で商品を更新したとき
// app/api/revalidate/route.js
import { revalidateTag } from 'next/cache';

export async function POST(request) {
  revalidateTag('products'); // 'products'タグのキャッシュを即座に無効化
  return Response.json({ revalidated: true });
}

複数のデータソースからの並列取得

export default async function Dashboard() {
  // 並列実行で高速化
  const [user, posts, analytics] = await Promise.all([
    fetch('https://api.example.com/user').then(r => r.json()),
    fetch('https://api.example.com/posts').then(r => r.json()),
    fetch('https://api.example.com/analytics').then(r => r.json())
  ]);
  
  return (
    <div>
      <UserProfile user={user} />
      <PostList posts={posts} />
      <AnalyticsChart data={analytics} />
    </div>
  );
}

実行タイムライン:

【逐次実行】
User取得(500ms)
  ↓
Posts取得(800ms)
  ↓
Analytics取得(600ms)
  ↓
合計: 1900ms

【並列実行】Promise.all使用
User取得(500ms)    ┐
Posts取得(800ms)    ├─ 同時実行
Analytics取得(600ms)┘
  ↓
合計: 800ms(最も遅い処理の時間)

3. Route Handlers(API Routes)

基本構造

// app/api/posts/route.js

export async function GET(request) {
  const posts = await db.posts.findMany();
  return Response.json(posts);
}

export async function POST(request) {
  const body = await request.json();
  const post = await db.posts.create({ data: body });
  return Response.json(post, { status: 201 });
}

動的ルート

// app/api/posts/[id]/route.js

export async function GET(request, { params }) {
  const { id } = params;
  const post = await db.posts.findUnique({ where: { id } });
  
  if (!post) {
    return Response.json({ error: 'Not found' }, { status: 404 });
  }
  
  return Response.json(post);
}

export async function PUT(request, { params }) {
  const { id } = params;
  const body = await request.json();
  
  const post = await db.posts.update({
    where: { id },
    data: body
  });
  
  return Response.json(post);
}

export async function DELETE(request, { params }) {
  const { id } = params;
  await db.posts.delete({ where: { id } });
  return Response.json({ message: 'Deleted' });
}

URLパラメータとヘッダーの扱い

// app/api/search/route.js

export async function GET(request) {
  // URLパラメータ
  const { searchParams } = new URL(request.url);
  const query = searchParams.get('q');
  const limit = searchParams.get('limit') || 10;
  const page = searchParams.get('page') || 1;
  
  // ヘッダー
  const authToken = request.headers.get('authorization');
  const userAgent = request.headers.get('user-agent');
  
  // クッキー
  const sessionId = request.cookies.get('session-id');
  
  // 検証
  if (!authToken) {
    return Response.json(
      { error: 'Unauthorized' },
      { status: 401 }
    );
  }
  
  const results = await searchPosts(query, { limit, page });
  
  return Response.json(results);
}

// 使用例: /api/search?q=nextjs&limit=5&page=1

ストリーミングレスポンス

// app/api/stream/route.js

export async function GET() {
  const encoder = new TextEncoder();
  
  const stream = new ReadableStream({
    async start(controller) {
      // 大量のデータを少しずつ送信
      for (let i = 0; i < 10; i++) {
        const data = await fetchChunk(i);
        controller.enqueue(encoder.encode(JSON.stringify(data) + '\n'));
        await new Promise(resolve => setTimeout(resolve, 100));
      }
      controller.close();
    }
  });
  
  return new Response(stream, {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache'
    }
  });
}

4. Server Actions

Server Actionsは、サーバー側の関数をクライアントから直接呼び出せるNext.js 13.4以降の機能です。API Routeを書かずにデータ変更ができます。

基本的な使い方

// app/actions.js
'use server'; // このファイル全体がServer Actions

export async function createPost(formData) {
  const title = formData.get('title');
  const content = formData.get('content');
  
  const post = await db.posts.create({
    data: { title, content }
  });
  
  revalidatePath('/posts'); // キャッシュを更新
  return post;
}
// app/posts/new/page.js

import { createPost } from '@/app/actions';

export default function NewPostPage() {
  return (
    <form action={createPost}>
      <input name="title" placeholder="タイトル" required />
      <textarea name="content" placeholder="本文" required />
      <button type="submit">投稿</button>
    </form>
  );
}

動作の流れ:

1. ユーザーがフォームを送信
   ↓
2. Next.jsが自動的にServer Actionを呼び出し
   ↓
3. サーバーでcreatePost関数が実行
   ↓
4. データベースに保存
   ↓
5. revalidatePath()でキャッシュ更新
   ↓
6. ページが自動的に再レンダリング

Client Componentでの使用

// app/components/DeleteButton.js
'use client';

import { deletePost } from '@/app/actions';
import { useTransition } from 'react';

export default function DeleteButton({ postId }) {
  const [isPending, startTransition] = useTransition();
  
  const handleDelete = () => {
    startTransition(async () => {
      await deletePost(postId);
    });
  };
  
  return (
    <button onClick={handleDelete} disabled={isPending}>
      {isPending ? '削除中...' : '削除'}
    </button>
  );
}
// app/actions.js
'use server';

export async function deletePost(postId) {
  await db.posts.delete({ where: { id: postId } });
  revalidatePath('/posts');
}

楽観的更新(Optimistic UI)

'use client';

import { likePost } from '@/app/actions';
import { useOptimistic } from 'react';

export default function LikeButton({ postId, initialLikes }) {
  const [optimisticLikes, addOptimisticLike] = useOptimistic(
    initialLikes,
    (state, amount) => state + amount
  );
  
  const handleLike = async () => {
    addOptimisticLike(1); // すぐにUIを更新
    await likePost(postId); // バックグラウンドで実際のいいねを処理
  };
  
  return (
    <button onClick={handleLike}>
      ❤️ {optimisticLikes}
    </button>
  );
}

体験の違い:

【楽観的更新なし】
クリック → 待つ... → サーバー完了 → 表示更新(遅く感じる)

【楽観的更新あり】
クリック → 即座に表示更新!→ バックグラウンドでサーバー処理

Server Actions vs API Routes

特徴 Server Actions API Routes
使用目的 フォーム送信、データ変更 RESTful API、外部統合
記述量 少ない(関数のみ) 多い(ルートファイル必要)
型安全性 完全(TypeScript) 手動で型定義
プログレッシブエンハンスメント あり(JSなしでも動作) なし
エンドポイント公開 なし あり(/api/...)
推奨用途 内部のデータ変更 外部クライアント向けAPI

実践例:完全な投稿管理システム

// app/actions.js
'use server';

import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';

export async function createPost(prevState, formData) {
  const title = formData.get('title');
  const content = formData.get('content');
  
  // バリデーション
  if (!title || title.length < 3) {
    return { error: 'タイトルは3文字以上必要です' };
  }
  
  if (!content || content.length < 10) {
    return { error: '本文は10文字以上必要です' };
  }
  
  try {
    const post = await db.posts.create({
      data: { title, content }
    });
    
    revalidatePath('/posts');
    redirect(`/posts/${post.id}`);
  } catch (error) {
    return { error: '投稿に失敗しました' };
  }
}

export async function updatePost(postId, formData) {
  const title = formData.get('title');
  const content = formData.get('content');
  
  await db.posts.update({
    where: { id: postId },
    data: { title, content }
  });
  
  revalidatePath(`/posts/${postId}`);
  revalidatePath('/posts');
}

export async function deletePost(postId) {
  await db.posts.delete({
    where: { id: postId }
  });
  
  revalidatePath('/posts');
  redirect('/posts');
}
// app/posts/new/page.js
'use client';

import { createPost } from '@/app/actions';
import { useFormState, useFormStatus } from 'react-dom';

function SubmitButton() {
  const { pending } = useFormStatus();
  
  return (
    <button type="submit" disabled={pending}>
      {pending ? '投稿中...' : '投稿する'}
    </button>
  );
}

export default function NewPostPage() {
  const [state, formAction] = useFormState(createPost, null);
  
  return (
    <form action={formAction}>
      <div>
        <label htmlFor="title">タイトル</label>
        <input 
          id="title"
          name="title" 
          placeholder="タイトルを入力" 
          required 
        />
      </div>
      
      <div>
        <label htmlFor="content">本文</label>
        <textarea 
          id="content"
          name="content" 
          placeholder="本文を入力" 
          required 
          rows={10}
        />
      </div>
      
      {state?.error && (
        <p className="error">{state.error}</p>
      )}
      
      <SubmitButton />
    </form>
  );
}

5. Client Componentsでのデータフェッチング

useEffectを使った基本パターン

'use client';

import { useState, useEffect } from 'react';

export default function Posts() {
  const [posts, setPosts] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  
  useEffect(() => {
    fetch('/api/posts')
      .then(r => {
        if (!r.ok) throw new Error('Failed to fetch');
        return r.json();
      })
      .then(data => {
        setPosts(data);
        setLoading(false);
      })
      .catch(err => {
        setError(err.message);
        setLoading(false);
      });
  }, []);
  
  if (loading) return <div>読み込み中...</div>;
  if (error) return <div>エラー: {error}</div>;
  
  return (
    <ul>
      {posts.map(post => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}

SWRを使った高度なパターン

SWRは、Next.jsの開発元Vercelが作ったデータフェッチングライブラリです。

npm install swr
'use client';

import useSWR from 'swr';

const fetcher = (url) => fetch(url).then(r => r.json());

export default function Posts() {
  const { data, error, isLoading, mutate } = useSWR('/api/posts', fetcher, {
    refreshInterval: 3000, // 3秒ごとに自動更新
    revalidateOnFocus: true // タブに戻ったときに再検証
  });
  
  if (error) return <div>エラーが発生しました</div>;
  if (isLoading) return <div>読み込み中...</div>;
  
  return (
    <div>
      <button onClick={() => mutate()}>更新</button>
      <ul>
        {data.map(post => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
    </div>
  );
}

SWRの主な機能

┌────────────────────────────────────┐
│ 1. 自動再検証                      │
│    - フォーカス時                  │
│    - 一定時間ごと                  │
│    - ネットワーク再接続時          │
└────────────────────────────────────┘
┌────────────────────────────────────┐
│ 2. キャッシュ管理                  │
│    - 自動的にキャッシュ            │
│    - 複数コンポーネント間で共有    │
└────────────────────────────────────┘
┌────────────────────────────────────┐
│ 3. Optimistic UI                   │
│    - 即座にUIを更新                │
│    - バックグラウンドで同期        │
└────────────────────────────────────┘
┌────────────────────────────────────┐
│ 4. エラーハンドリング              │
│    - 自動リトライ                  │
│    - エラー状態の管理              │
└────────────────────────────────────┘

SWRでの楽観的更新

'use client';

import useSWR from 'swr';

const fetcher = (url) => fetch(url).then(r => r.json());

export default function TodoList() {
  const { data: todos, mutate } = useSWR('/api/todos', fetcher);
  
  const addTodo = async (text) => {
    // 楽観的更新:UIを即座に更新
    const newTodo = { id: Date.now(), text, completed: false };
    mutate([...todos, newTodo], false); // false = 再検証しない
    
    // サーバーに送信
    try {
      await fetch('/api/todos', {
        method: 'POST',
        body: JSON.stringify(newTodo)
      });
      // 成功したら再検証
      mutate();
    } catch (error) {
      // 失敗したら元に戻す
      mutate(todos);
    }
  };
  
  return (
    <div>
      <button onClick={() => addTodo('新しいタスク')}>
        追加
      </button>
      <ul>
        {todos?.map(todo => (
          <li key={todo.id}>{todo.text}</li>
        ))}
      </ul>
    </div>
  );
}

6. データフェッチング戦略の選び方

判断フローチャート

┌────────────────────────────────────────┐
│ データの性質を考える                   │
└────────────────────────────────────────┘
         ↓
    変更頻度は?
         ↓
    ┌────┴────┐
    │         │
 ほぼ変わらない  頻繁に変わる
    │         │
    ↓         ↓
静的レンダリング  動的レンダリング
+ force-cache    + no-store
    │         │
    │         ↓
    │    誰が必要とする?
    │         │
    │    ┌────┴────┐
    │    │         │
    │  サーバー  クライアント
    │    │         │
    │    ↓         ↓
    │ Server    Client
    │ Components Components
    │  + fetch    + SWR
    │         
    └─→ ユーザー固有?
         │
    ┌────┴────┐
    │         │
   YES       NO
    │         │
    ↓         ↓
 ISR with    ISR
 tags       (時間ベース)

具体的な判断フロー

Q: ブログ記事を表示したい
↓
A1: 記事は頻繁に更新される?
   → いいえ → 静的レンダリング + ISR (revalidate: 3600)

Q: ユーザーダッシュボードを作りたい
↓
A1: ユーザーごとに違うデータ?
   → はい
A2: リアルタイム性が重要?
   → はい → Client Components + SWR
   → いいえ → Server Components + 動的レンダリング

Q: コメント機能を追加したい
↓
A1: 追加後すぐに反映したい?
   → はい → Server Actions + revalidatePath
A2: 楽観的更新が必要?
   → はい → useOptimistic も追加

Q: 商品一覧を表示したい
↓
A1: 在庫や価格が変動する?
   → はい
A2: 管理画面で更新したら即反映?
   → はい → ISR + revalidateTag
   → いいえ → ISR (revalidate: 60)

パターン別の推奨実装

ユースケース 推奨手法 理由
企業サイト Server Components + force-cache ほぼ変わらない
ブログ Server Components + ISR たまに更新
ECサイト商品 Server Components + タグISR 管理画面で更新時に反映
ユーザー設定 Server Components + 動的 ユーザー固有
ダッシュボード Client Components + SWR リアルタイム
チャット Client Components + WebSocket 双方向通信
フォーム送信 Server Actions データ変更
外部API Route Handlers 外部統合

まとめ

この記事では、Next.jsのデータフェッチング戦略を詳しく学びました:

  • Server Componentsでのfetch: 4つのキャッシングパターン
  • Route Handlers: REST APIエンドポイントの実装
  • Server Actions: フォーム送信とデータ変更
  • Client Components + SWR: 高度なクライアントサイドフェッチング
  • ✅ データフェッチング戦略の選び方

次回は「キャッシング戦略」について解説します。Next.jsの4つのキャッシュ層を完全に理解し、パフォーマンスを最大化する方法を学びます。

お楽しみに!


シリーズ記事

Discussion