Next.js データフェッチング実務パターン解説 | 業務で使える実装コード集

React / Next.js

Next.js データフェッチング業務パターン完全ガイド

Next.jsでのデータフェッチングは、アプリケーション設計の中核をなす重要な実装です。プロジェクト初期段階では「とにかく動けばいい」という感覚でコードを書いてしまいがちですが、本番運用を考えると様々な選択肢を理解し、適切なパターンを選ぶ必要があります。本記事では、実務で実際に使われているデータフェッチングパターンを、その背景と共に解説します。

Next.js データフェッチングの基本

Next.jsには複数のデータフェッチング方法があります:

  • getServerSideProps:リクエストごとにサーバーで実行(SSR)
  • getStaticProps:ビルド時に実行、結果をキャッシュ(SSG)
  • getStaticPaths:動的ルートの静的生成
  • API Routes:カスタムAPIエンドポイント
  • クライアント側フェッチ:useEffect内のfetch

これらは「いつ」「どこで」実行するかで性質が大きく変わります。本番環境では、キャッシング戦略、パフォーマンス、データの鮮度のバランスを取ることが重要です。

業務でのユースケース分析

ユースケース1:リアルタイムデータが必要な場合

ユーザーダッシュボード、在庫管理画面、トレーディング情報など、常に最新のデータが必要なケースです。リクエストごとにDBから取得する必要があります。

ユースケース2:更新頻度が低いデータ

ブログ記事一覧、商品カテゴリー、固定的なコンテンツなど、更新が少ないデータです。ISR(Incremental Static Regeneration)の活用で、キャッシュメリットとデータ鮮度のバランスが取れます。

ユースケース3:ユーザー認証が必要なデータ

個人設定、注文履歴、プロフィール情報など、認証が必須のデータです。API Routesとクライアント側フェッチの組み合わせが定石です。

ユースケース4:複数データソースの統合

複数のDBやExternal APIから並行してデータを取得し、組み合わせて返す必要があるケースです。サーバーサイドで処理することでクライアントの負担を減らします。

実装コード:実務パターン集

パターン1:SSRでリアルタイムデータを取得

ユーザーダッシュボードなど、毎回最新データが必要な場合の実装です:

// pages/dashboard.tsx
import { GetServerSideProps } from 'next';
import { useState, useEffect } from 'react';

interface UserStats {
  userId: string;
  totalPurchase: number;
  lastLoginDate: string;
  pendingOrders: number;
}

interface DashboardProps {
  initialData: UserStats;
}

export default function Dashboard({ initialData }: DashboardProps) {
  const [stats, setStats] = useState(initialData);
  const [isRefreshing, setIsRefreshing] = useState(false);

  const handleManualRefresh = async () => {
    setIsRefreshing(true);
    try {
      const res = await fetch('/api/user/stats');
      const data = await res.json();
      setStats(data);
    } catch (error) {
      console.error('Failed to refresh stats:', error);
    } finally {
      setIsRefreshing(false);
    }
  };

  return (
    

ダッシュボード

総購入額: {stats.totalPurchase.toLocaleString()}円

保留中の注文: {stats.pendingOrders}件

最終ログイン: {new Date(stats.lastLoginDate).toLocaleString('ja-JP')}

\n
\n );\n}\n\nexport const getServerSideProps: GetServerSideProps = async (context) => {\n const { req } = context;\n const cookie = req.headers.cookie;\n\n try {\n // DBまたはAPIから直接データを取得\n const response = await fetch(`${process.env.INTERNAL_API_URL}/user/stats`, {\n headers: {\n cookie: cookie || '',\n },\n });\n\n if (!response.ok) {\n return {\n notFound: true,\n };\n }\n\n const initialData: UserStats = await response.json();\n\n return {\n props: {\n initialData,\n },\n revalidate: false, // SSRなので毎回実行\n };\n } catch (error) {\n console.error('Failed to fetch dashboard data:', error);\n return {\n notFound: true,\n };\n }\n};

このパターンの利点は、認証情報を含めやすく、常に最新データを表示できることです。欠点はサーバー負荷が高くなる点で、大規模なPVを持つサイトでは工夫が必要です。

パターン2:ISRでバランスの取れた更新

ブログ記事一覧のように、更新頻度は低いが完全に静的ではないケースです:

// pages/blog/index.tsx\nimport { GetStaticProps } from 'next';\n\ninterface BlogPost {\n  id: string;\n  title: string;\n  slug: string;\n  publishedAt: string;\n  excerpt: string;\n}\n\ninterface BlogListProps {\n  posts: BlogPost[];\n  totalCount: number;\n}\n\nexport default function BlogList({ posts, totalCount }: BlogListProps) {\n  return (\n    
\n

ブログ

\n

全{totalCount}件の記事

\n
    \n {posts.map((post) => (\n
  • \n {post.title}\n \n
  • \n ))}\n
\n
\n );\n}\n\nexport const getStaticProps: GetStaticProps = async () => {\n try {\n // DBからブログ記事を取得\n const posts = await fetchBlogPosts({\n limit: 20,\n sort: 'publishedAt',\n order: 'desc',\n });\n\n const totalCount = await countBlogPosts();\n\n return {\n props: {\n posts,\n totalCount,\n },\n // 60秒ごとにバックグラウンドで再生成\n revalidate: 60,\n };\n } catch (error) {\n console.error('Failed to fetch blog posts:', error);\n return {\n notFound: true,\n revalidate: 10, // エラー時は短めの再試行\n };\n }\n};

ISRの真価は、ビルド時の生成速度と本番運用時の更新のバランスにあります。新しい記事が公開されても、60秒以内に自動更新されるため、手動デプロイが不要です。

パターン3:認証が必要なデータの取得

個人情報やプライベートなデータは、API Routesで認証チェックを行った上でクライアント側から取得するのが標準的です:

// pages/api/user/profile.ts\nimport { NextApiRequest, NextApiResponse } from 'next';\nimport { verifyAuth } from '@/lib/auth';\nimport { getUserProfile } from '@/lib/database';\n\ninterface UserProfile {\n  id: string;\n  email: string;\n  displayName: string;\n  avatar: string;\n  createdAt: string;\n}\n\ntype ResponseData = UserProfile | { error: string };\n\nexport default async function handler(\n  req: NextApiRequest,\n  res: NextApiResponse\n) {\n  if (req.method !== 'GET') {\n    res.status(405).json({ error: 'Method not allowed' });\n    return;\n  }\n\n  try {\n    // トークンから認証情報を取得\n    const authToken = req.cookies.authToken;\n    const userId = await verifyAuth(authToken);\n\n    if (!userId) {\n      res.status(401).json({ error: 'Unauthorized' });\n      return;\n    }\n\n    // DBからプロフィール情報を取得\n    const profile = await getUserProfile(userId);\n\n    // キャッシュ設定:プライベートデータなのでprivateを指定\n    res.setHeader('Cache-Control', 'private, max-age=300');\n    res.status(200).json(profile);\n  } catch (error) {\n    console.error('Failed to fetch user profile:', error);\n    res.status(500).json({ error: 'Internal server error' });\n  }\n}\n\n// pages/profile.tsx\nimport { useEffect, useState } from 'react';\n\ninterface UserProfile {\n  id: string;\n  email: string;\n  displayName: string;\n  avatar: string;\n  createdAt: string;\n}\n\nexport default function ProfilePage() {\n  const [profile, setProfile] = useState(null);\n  const [loading, setLoading] = useState(true);\n  const [error, setError] = useState(null);\n\n  useEffect(() => {\n    const fetchProfile = async () => {\n      try {\n        const res = await fetch('/api/user/profile');\n        if (!res.ok) {\n          throw new Error('Failed to fetch profile');\n        }\n        const data: UserProfile = await res.json();\n        setProfile(data);\n      } catch (err) {\n        setError(err instanceof Error ? err.message : 'Unknown error');\n      } finally {\n        setLoading(false);\n      }\n    };\n\n    fetchProfile();\n  }, []);\n\n  if (loading) return 
読み込み中...
;\n if (error) return
エラー: {error}
;\n if (!profile) return
プロフィール情報がありません
;\n\n return (\n
\n

プロフィール

\n \"アバター\"\n

名前: {profile.displayName}

\n

メール: {profile.email}

\n

作成日: {new Date(profile.createdAt).toLocaleDateString('ja-JP')}

\n
\n );\n}

パターン4:複数のデータソースからの統合取得

実務では、複数のDB や外部API から並行してデータを取得する必要があることが多いです:

// pages/api/product/[id]/detail.ts\nimport { NextApiRequest, NextApiResponse } from 'next';\nimport { getProduct } from '@/lib/database/products';\nimport { getInventory } from '@/lib/database/inventory';\nimport { getReviews } from '@/lib/database/reviews';\nimport { fetchExternalRecommendations } from '@/lib/external-api';\n\ninterface ProductDetail {\n  product: {\n    id: string;\n    name: string;\n    price: number;\n    description: string;\n  };\n  inventory: {\n    inStock: number;\n    reserved: number;\n  };\n  reviews: {\n    averageRating: number;\n    count: number;\n  };\n  recommendations: string[];\n}\n\ntype ResponseData = ProductDetail | { error: string };\n\nexport default async function handler(\n  req: NextApiRequest,\n  res: NextApiResponse\n) {\n  const { id } = req.query;\n\n  if (!id || typeof id !== 'string') {\n    res.status(400).json({ error: 'Invalid product ID' });\n    return;\n  }\n\n  try {\n    // 複数のデータ取得を並行実行\n    const [product, inventory, reviews, recommendations] = await Promise.all([\n      getProduct(id),\n      getInventory(id),\n      getReviews(id),\n      fetchExternalRecommendations(id),\n    ]);\n\n    if (!product) {\n      res.status(404).json({ error: 'Product not found' });\n      return;\n    }\n\n    const detail: ProductDetail = {\n      product,\n      inventory,\n      reviews,\n      recommendations,\n    };\n\n    // 公開情報なので、CDNでキャッシュ可能\n    res.setHeader('Cache-Control', 'public, max-age=3600, s-maxage=86400');\n    res.status(200).json(detail);\n  } catch (error) {\n    console.error('Failed to fetch product detail:', error);\n    res.status(500).json({ error: 'Internal server error' });\n  }\n}

このパターンの重要なポイントは、複数のPromiseを Promise.all() で並行実行することです。これにより、シリアルな実行より大幅に高速化されます。

パターン5:SWRを使ったクライアント側フェッチ

リアルタイム性が求められるデータや、ユーザーアクションに基づくデータ取得では、SWR(stale-while-revalidate)ライブラリが便利です:

// pages/orders/index.tsx\nimport useSWR from 'swr';\n\ninterface Order {\n  id: string;\n  orderNumber: string;\n  totalAmount: number;\n  status: 'pending' | 'shipped' | 'delivered';\n  createdAt: string;\n}\n\nconst fetcher = (url: string) => fetch(url).then((res) => res.json());\n\nexport default function OrdersPage() {\n  // 初回はキャッシュから返し、バックグラウンドで新しいデータを取得\n  const { data, error, isLoading, mutate } = useSWR(\n    '/api/user/orders',\n    fetcher,\n    {\n      revalidateOnFocus: true,\n      revalidateOnReconnect: true,\n      dedupingInterval: 60000, // 60秒以内の同じリクエストは重複排除\n    }\n  );\n\n  const handleRefresh = () => {\n    mutate();\n  };\n\n  if (error) return 
注文の取得に失敗しました
;\n if (isLoading) return
読み込み中...
;\n\n return (\n
\n

注文一覧

\n \n \n \n \n \n \n \n \n \n \n \n {data?.map((order) => (\n \n \n \n \n \n \n ))}\n \n
注文番号金額ステータス注文日
{order.orderNumber}{order.totalAmount.toLocaleString()}円{order.status}{new Date(order.createdAt).toLocaleDateString('ja-JP')}
\n
\n );\n}

SWRの活用により、ユーザーの操作感が向上します。ページにアクセスした時点で過去のキャッシュデータを表示しつつ、バックグラウンドで新しいデータを取得するため、待機時間が最小化されます。

よくある応用パターン

パターン:エラーハンドリングとフォールバック

本番環境では、データ取得失敗時の対応が不可欠です:

// lib/dataFetching.ts\ninterface FetchOptions {\n  retries?: number;\n  timeout?: number;\n  fallbackData?: any;\n}\n\nasync function fetchWithRetry(\n  url: string,\n  options: FetchOptions = {}\n) {\n  const { retries = 3, timeout = 5000, fallbackData = null } = options;\n\n  for (let attempt = 0; attempt < retries; attempt++) {\n    try {\n      const controller = new AbortController();\n      const timeoutId = setTimeout(() => controller.abort(), timeout);\n\n      const response = await fetch(url, {\n        signal: controller.signal,\n      });\n\n      clearTimeout(timeoutId);\n\n      if (!response.ok) {\n        throw new Error(`HTTP ${response.status}`);\n      }\n\n      return await response.json();\n    } catch (error) {\n      console.warn(`Attempt ${attempt + 1}/${retries} failed:`, error);\n\n      if (attempt === retries - 1) {\n        // 最後の試行が失敗した場合\n        if (fallbackData) {\n          console.warn('Returning fallback data');\n          return fallbackData;\n        }\n        throw error;\n      }\n\n      // 指数バックオフで待機\n      const delayMs = Math.min(1000 * Math.pow(2, attempt), 10000);\n      await new Promise((resolve) => setTimeout(resolve, delayMs));\n    }\n  }\n}\n\n// pages/api/resilient-endpoint.ts\nexport default async function handler(\n  req: NextApiRequest,\n  res: NextApiResponse\n) {\n  try {\n    const data = await fetchWithRetry('/api/critical-data', {\n      retries: 3,\n      timeout: 5000,\n      fallbackData: { cached: true, timestamp: Date.now() },\n    });\n    res.status(200).json(data);\n  } catch (error) {\n    res.status(500).json({ error: 'Failed to fetch data' });\n  }\n}

パターン:キャッシング戦略

Next.jsのキャッシュ設定は、レスポンスの Cache-Control ヘッダーで制御します:

// pages/api/public/categories.ts\nimport { NextApiRequest, NextApiResponse } from 'next';\n\nexport default async function handler(\n  req: NextApiRequest,\n  res: NextApiResponse\n) {\n  try {\n    // 商品カテゴリーなど、ほぼ変わらないデータ\n    const categories = await fetchCategories();\n\n    // CDN(Vercelの場合)で24時間キャッシュ\n    // ブラウザのローカルキャッシュで1時間有効\n    res.setHeader(\n      'Cache-Control',\n      'public, max-age=3600, s-maxage=86400'\n    );\n    res.status(200).json(categories);\n  } catch (error) {\n    // エラーレスポンスはキャッシュしない\n    res.setHeader('Cache-Control', 'no-store');\n    res.status(500).json({ error: 'Failed to fetch categories' });\n  }\n}

パターン:pagination 処理

大量データを扱う場合、ページネーション対応が必須です:

// pages/api/products.ts\nimport { NextApiRequest, NextApiResponse } from 'next';\n\ninterface PaginatedResponse {\n  items: T[];\n  pagination: {\n    page: number;\n    limit: number;\n    total: number;\n    hasNext: boolean;\n  };\n}\n\nexport default async function handler(\n  req: NextApiRequest,\n  res: NextApiResponse>\n) {\n  const page = Math.max(1, parseInt(req.query.page as string) || 1);\n  const limit = Math.min(100, parseInt(req.query.limit as string) || 20);\n\n  try {\n    const offset = (page - 1) * limit;\n    const [items, total] = await Promise.all([\n      fetchProducts(offset, limit),\n      countProducts(),\n    ]);\n\n    res.setHeader('Cache-Control', 'public, max-age=300');\n    res.status(200).json({\n      items,\n      pagination: {\n        page,\n        limit,\n        total,\n        hasNext: offset + limit < total,\n      },\n    });\n  } catch (error) {\n    res.status(500).json({ error: 'Failed to fetch products' } as any);\n  }\n}

注意点と落とし穴

1. 認証情報の扱い

クライアント側での認証情報取得は要注意です。認証トークンはHttpOnly クッキーに保存し、JavaScript からはアクセスできないようにすることが推奨されます:

// ❌ 危険:トークンをJavaScriptで扱う\nconst token = localStorage.getItem('token');\nfetch('/api/data', {\n  headers: { 'Authorization': `Bearer ${token}` }\n});\n\n// ✅ 推奨:HttpOnlyクッキーに保存し、自動で送信される\n// サーバー側でクッキーをセット\nres.setHeader('Set-Cookie', 'authToken=...; HttpOnly; Secure; SameSite=Strict');

2. getServerSideProps での過度な使用

すべてのページでgetServerSidePropsを使うと、サーバー負荷が増加します。キャッシュできるデータはgetStaticPropsやISRを活用してください。

3. N+1 クエリ問題

複数のアイテムをループして各々にデータベースアクセスするのは避けてください:

// ❌ N+1 クエリ\nconst products = await getProducts();\nconst enrichedProducts = await Promise.all(\n  products.map(p => getProductDetails(p.id))\n);\n\n// ✅ 1クエリで取得\nconst enrichedProducts = await getProductsWithDetails();

4. タイムアウト管理

External APIへのアクセス時は必ずタイムアウトを設定してください:

// ✅ タイムアウト付きfetch\nconst controller = new AbortController();\nconst timeoutId = setTimeout(() => controller.abort(), 5000);\n\ntry {\n  const response = await fetch(externalUrl, {\n    signal: controller.signal,\n  });\n} finally {\n  clearTimeout(timeoutId);\n}

5. データ鮮度とパフォーマンスのトレードオフ

完全な最新データを求めすぎると、サーバー負荷が増加します。業務要件に応じて、適切な「古さ」を許容することが重要です。

実務での選択基準

どのパターンを選ぶべきかの判断フローは以下の通りです:

  1. 認証が必要か? → Yes → API Routes + クライアント側フェッチ
  2. リアルタイムデータが必須か? → Yes → getServerSideProps または SWR
  3. 更新頻度が低いか? → Yes → getStaticProps + ISR
  4. 複数のデータソースを統合する? → Yes → API Routes内でPromise.all()を使用
  5. ユーザーアクションで動的に取得? → Yes → SWRまたはuseEffect

まとめ

Next.jsのデータフェッチングは、単に「動く」ことだけを目指すのではなく、本番運用を見据えた設計が重要です。実務では、以下のポイントを意識してください:

  • 認証情報の安全な扱い
  • 適切なキャッシング戦略
  • エラーハンドリングとフォールバック
  • パフォーマンスと データ鮮度のバランス
  • タイムアウトと リトライ処理

これらを組み合わせることで、スケーラブルで堅牢なNext.jsアプリケーションが実現できます。プロジェクトの要件に応じて、最適なパターンを選択し、継続的に改善していくことが大切です。

タイトルとURLをコピーしました