Next.js getServerSidePropsの業務実装パターン|実務で使える認証・データ取得・エラーハンドリング

React / Next.js

Next.js getServerSidePropsの業務実装パターン|実務で使える認証・データ取得・エラーハンドリング

\n\n

\n

1. getServerSidePropsとは|簡易的な解説

\n\n

Next.jsのgetServerSidePropsは、ページコンポーネントをレンダリングする前にサーバー側で実行される関数です。リクエストごとに呼び出されるため、常に最新のデータを取得できるサーバーサイドレンダリング(SSR)を実現します。

\n\n

クライアントサイドで実行されるgetStaticPropsと異なり、getServerSidePropsはページがリクエストされるたびに実行されます。つまり、認証が必要なページ、頻繁に更新されるデータ、ユーザー固有の情報が必要なシーンに最適です。

\n\n

基本的な構文は以下の通りです:

\n\n

export async function getServerSideProps(context) {\n  return {\n    props: {}\n  }\n}\n

\n\n

関数の引数contextには、リクエストオブジェクト、レスポンスオブジェクト、クエリパラメータなどが含まれます。これらを活用することで、業務で必要な多くの要件に対応できます。

\n

\n\n

\n

2. 業務でのユースケース

\n\n

実務では、以下のようなシーンでgetServerSidePropsが活躍します:

\n\n

    \n

  • ユーザー認証が必要なページ:セッション情報をチェックし、認証されていなければログインページにリダイレクト
  • \n

  • 会員情報の表示ページ:ユーザーIDごとに異なるデータを取得する必要がある場合
  • \n

  • 管理画面のダッシュボード:アクセス権限を確認してからデータを返す
  • \n

  • 外部APIからのリアルタイムデータ取得:常に最新のデータを表示する必要があるページ
  • \n

  • 動的ルートのページ生成:URLパラメータに基づいて異なるデータを取得
  • \n

  • キャッシュ制御が必要なデータ:Cache-Controlヘッダーをサーバーサイドで制御する場合
  • \n

\n\n

これらのユースケースはeコマースサイト、SaaS、ポータルサイト、会員制メディアなど、様々な業務アプリケーションで日常的に出現します。

\n

\n\n

\n

3. 実装コード|業務でそのまま使えるパターン

\n\n

3-1. 認証チェック付きのページ実装

\n\n

最も一般的なパターンが、セッション認証を確認してからデータを表示するページです。以下は、実務で即座に使える実装例です:

\n\n

// pages/dashboard.tsx\nimport { GetServerSideProps } from 'next';\nimport { getSession } from 'next-auth/react';\nimport { Session } from 'next-auth';\n\ninterface DashboardProps {\n  userName: string;\n  userEmail: string;\n  dashboardData: {\n    totalSales: number;\n    activeUsers: number;\n    lastUpdated: string;\n  };\n}\n\nfunction DashboardPage({ userName, userEmail, dashboardData }: DashboardProps) {\n  return (\n    
\n

ダッシュボード

\n

ようこそ、{userName}様

\n

メール:{userEmail}

\n
\n

売上統計

\n

総売上:¥{dashboardData.totalSales.toLocaleString()}

\n

アクティブユーザー:{dashboardData.activeUsers}

\n

更新時刻:{dashboardData.lastUpdated}

\n
\n
\n );\n}\n\nexport const getServerSideProps: GetServerSideProps = async (context) => {\n const session = await getSession({ req: context.req });\n\n // 認証されていない場合はログインページへリダイレクト\n if (!session) {\n return {\n redirect: {\n destination: '/login',\n permanent: false,\n },\n };\n }\n\n try {\n // 認証済みユーザーのデータを取得\n const dashboardResponse = await fetch(\n `${process.env.NEXT_PUBLIC_API_URL}/api/dashboard`,\n {\n headers: {\n Authorization: `Bearer ${session.user.accessToken}`,\n },\n }\n );\n\n if (!dashboardResponse.ok) {\n throw new Error('ダッシュボードデータの取得に失敗しました');\n }\n\n const dashboardData = await dashboardResponse.json();\n\n return {\n props: {\n userName: session.user.name,\n userEmail: session.user.email,\n dashboardData,\n },\n // ページキャッシュを10秒に設定(リアルタイムデータのため短く設定)\n revalidate: 10,\n };\n } catch (error) {\n console.error('getServerSideProps error:', error);\n\n // エラー時は一度だけリトライするため、notFoundではなくコンポーネント側でエラーハンドリング\n return {\n props: {\n userName: session.user.name,\n userEmail: session.user.email,\n dashboardData: {\n totalSales: 0,\n activeUsers: 0,\n lastUpdated: new Date().toISOString(),\n },\n },\n revalidate: 5,\n };\n }\n};\n\nexport default DashboardPage;\n

\n\n

3-2. 動的ルートでのデータ取得

\n\n

ユーザーIDや商品IDなどのパラメータに基づいてデータを取得する場合、以下のように実装します:

\n\n

// pages/users/[userId].tsx\nimport { GetServerSideProps } from 'next';\n\ninterface UserProfileProps {\n  userId: string;\n  userName: string;\n  userEmail: string;\n  joinDate: string;\n  isPremium: boolean;\n  profileImage: string;\n}\n\nfunction UserProfilePage(props: UserProfileProps) {\n  const { userId, userName, userEmail, joinDate, isPremium, profileImage } = props;\n\n  return (\n    
\n {userName}\n

{userName}

\n

ユーザーID:{userId}

\n

メール:{userEmail}

\n

登録日:{new Date(joinDate).toLocaleDateString('ja-JP')}

\n {isPremium && プレミアム会員}\n
\n );\n}\n\nexport const getServerSideProps: GetServerSideProps = async (context) => {\n const { userId } = context.params as { userId: string };\n\n // ユーザーIDの妥当性チェック(数値のみ許可)\n if (!/^\\d+$/.test(userId)) {\n return {\n notFound: true,\n };\n }\n\n try {\n const userResponse = await fetch(\n `${process.env.NEXT_PUBLIC_API_URL}/api/users/${userId}`,\n {\n headers: {\n 'User-Agent': context.req.headers['user-agent'] || '',\n },\n // タイムアウト設定(3秒で切断)\n signal: AbortSignal.timeout(3000),\n }\n );\n\n if (userResponse.status === 404) {\n return {\n notFound: true,\n };\n }\n\n if (!userResponse.ok) {\n throw new Error(`API error: ${userResponse.status}`);\n }\n\n const userData = await userResponse.json();\n\n return {\n props: {\n userId,\n userName: userData.name,\n userEmail: userData.email,\n joinDate: userData.createdAt,\n isPremium: userData.isPremium,\n profileImage: userData.profileImage,\n },\n // ユーザー情報は比較的安定しているため、キャッシュを60秒に設定\n revalidate: 60,\n };\n } catch (error) {\n console.error(`Failed to fetch user ${userId}:`, error);\n\n return {\n notFound: true,\n };\n }\n};\n\nexport default UserProfilePage;\n

\n\n

3-3. 権限チェック付きの管理画面

\n\n

管理権限をチェックして、必要に応じてアクセスを制限するパターンです:

\n\n

// pages/admin/users.tsx\nimport { GetServerSideProps } from 'next';\nimport { getSession } from 'next-auth/react';\nimport { Session } from 'next-auth';\n\ninterface AdminUsersPageProps {\n  users: Array<{\n    id: string;\n    name: string;\n    email: string;\n    status: 'active' | 'inactive' | 'suspended';\n    role: 'user' | 'moderator' | 'admin';\n    createdAt: string;\n  }>;\n  totalCount: number;\n  pageNumber: number;\n}\n\nfunction AdminUsersPage({ users, totalCount, pageNumber }: AdminUsersPageProps) {\n  return (\n    
\n

ユーザー管理

\n

合計:{totalCount}名

\n \n \n \n \n \n \n \n \n \n \n \n \n {users.map((user) => (\n \n \n \n \n \n \n \n \n ))}\n \n
ID名前メールステータス権限登録日
{user.id}{user.name}{user.email}{user.status}{user.role}{new Date(user.createdAt).toLocaleDateString('ja-JP')}
\n

ページ {pageNumber}

\n
\n );\n}\n\nexport const getServerSideProps: GetServerSideProps = async (context) => {\n const session = await getSession({ req: context.req });\n const pageNumber = parseInt(context.query.page as string) || 1;\n\n // 認証チェック\n if (!session) {\n return {\n redirect: {\n destination: '/login',\n permanent: false,\n },\n };\n }\n\n // 管理者権限チェック\n if (session.user.role !== 'admin') {\n return {\n redirect: {\n destination: '/',\n permanent: false,\n },\n };\n }\n\n try {\n // ページネーション処理\n const pageSize = 20;\n const skip = (pageNumber - 1) * pageSize;\n\n const usersResponse = await fetch(\n `${process.env.NEXT_PUBLIC_API_URL}/api/admin/users?skip=${skip}&limit=${pageSize}`,\n {\n headers: {\n Authorization: `Bearer ${session.user.accessToken}`,\n 'X-Admin-Request': 'true',\n },\n signal: AbortSignal.timeout(5000),\n }\n );\n\n if (!usersResponse.ok) {\n throw new Error('ユーザーリスト取得失敗');\n }\n\n const data = await usersResponse.json();\n\n return {\n props: {\n users: data.users,\n totalCount: data.totalCount,\n pageNumber,\n },\n // 管理画面は頻繁に更新されるため、短いキャッシュ時間を設定\n revalidate: 5,\n };\n } catch (error) {\n console.error('Admin users fetch error:', error);\n\n return {\n props: {\n users: [],\n totalCount: 0,\n pageNumber,\n },\n revalidate: 2,\n };\n }\n};\n\nexport default AdminUsersPage;\n

\n

\n\n

\n

4. よくある応用パターン

\n\n

4-1. 複数のAPIを並列実行する

\n\n

業務では、複数のデータソースから情報を取得することが多いです。Promise.allを使って並列実行し、パフォーマンスを最適化します:

\n\n

export const getServerSideProps: GetServerSideProps = async (context) => {\n  const session = await getSession({ req: context.req });\n\n  if (!session) {\n    return { redirect: { destination: '/login', permanent: false } };\n  }\n\n  try {\n    // 複数のAPIリクエストを並列実行\n    const [profileRes, ordersRes, notificationsRes] = await Promise.all([\n      fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/profile`, {\n        headers: { Authorization: `Bearer ${session.user.accessToken}` },\n      }),\n      fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/orders`, {\n        headers: { Authorization: `Bearer ${session.user.accessToken}` },\n      }),\n      fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/notifications`, {\n        headers: { Authorization: `Bearer ${session.user.accessToken}` },\n      }),\n    ]);\n\n    if (!profileRes.ok || !ordersRes.ok || !notificationsRes.ok) {\n      throw new Error('一部のAPIリクエストが失敗しました');\n    }\n\n    const [profile, orders, notifications] = await Promise.all([\n      profileRes.json(),\n      ordersRes.json(),\n      notificationsRes.json(),\n    ]);\n\n    return {\n      props: {\n        profile,\n        orders,\n        notifications,\n      },\n      revalidate: 10,\n    };\n  } catch (error) {\n    console.error('Parallel API calls failed:', error);\n    return {\n      notFound: true,\n    };\n  }\n};\n

\n\n

4-2. リダイレクトとデータ取得の組み合わせ

\n\n

条件に基づいて異なるページにリダイレクトしたり、データを加工したりする場合:

\n\n

export const getServerSideProps: GetServerSideProps = async (context) => {\n  const { orderId } = context.params as { orderId: string };\n  const session = await getSession({ req: context.req });\n\n  if (!session) {\n    return { redirect: { destination: '/login', permanent: false } };\n  }\n\n  try {\n    const orderResponse = await fetch(\n      `${process.env.NEXT_PUBLIC_API_URL}/api/orders/${orderId}`,\n      {\n        headers: { Authorization: `Bearer ${session.user.accessToken}` },\n      }\n    );\n\n    if (!orderResponse.ok) {\n      return { notFound: true };\n    }\n\n    const order = await orderResponse.json();\n\n    // ビジネスロジック:特定の条件でリダイレクト\n    if (order.status === 'cancelled') {\n      return {\n        redirect: {\n          destination: '/orders',\n          permanent: false,\n        },\n      };\n    }\n\n    // データの変換・加工\n    const processedOrder = {\n      ...order,\n      formattedPrice: `¥${order.totalPrice.toLocaleString()}`,\n      statusLabel: getStatusLabel(order.status),\n      estimatedDelivery: new Date(order.createdAt).getTime() + 7 * 24 * 60 * 60 * 1000,\n    };\n\n    return {\n      props: {\n        order: processedOrder,\n      },\n      revalidate: 30,\n    };\n  } catch (error) {\n    console.error('Order fetch failed:', error);\n    return {\n      notFound: true,\n    };\n  }\n};\n\nfunction getStatusLabel(status: string): string {\n  const labels: Record = {\n    pending: '処理中',\n    confirmed: '確定',\n    shipped: '発送済み',\n    delivered: '配達済み',\n    cancelled: 'キャンセル',\n  };\n  return labels[status] || status;\n}\n

\n\n

4-3. クエリパラメータの検証と処理

\n\n

フィルタリングやソートなど、クエリパラメータに基づく動的な処理:

\n\n

interface ProductListProps {\n  products: Array<{ id: string; name: string; price: number; category: string }>;\n  currentPage: number;\n  totalPages: number;\n  sortBy: string;\n  category: string;\n}\n\nexport const getServerSideProps: GetServerSideProps = async (context) => {\n  // クエリパラメータの取得と妥当性チェック\n  const page = Math.max(1, parseInt(context.query.page as string) || 1);\n  const sortBy = (context.query.sort as string) || 'popularity';\n  const category = (context.query.category as string) || '';\n  const pageSize = 20;\n\n  // 許可されたソート順のみを受け付ける(インジェクション対策)\n  const allowedSorts = ['popularity', 'price_asc', 'price_desc', 'newest'];\n  const validSort = allowedSorts.includes(sortBy) ? sortBy : 'popularity';\n\n  // 許可されたカテゴリのみ処理\n  const allowedCategories = ['electronics', 'clothing', 'books', 'food'];\n  const validCategory = category && allowedCategories.includes(category) ? category : '';\n\n  try {\n    const queryParams = new URLSearchParams();\n    queryParams.append('page', String(page));\n    queryParams.append('limit', String(pageSize));\n    queryParams.append('sort', validSort);\n    if (validCategory) {\n      queryParams.append('category', validCategory);\n    }\n\n    const productsResponse = await fetch(\n      `${process.env.NEXT_PUBLIC_API_URL}/api/products?${queryParams.toString()}`,\n      {\n        signal: AbortSignal.timeout(5000),\n      }\n    );\n\n    if (!productsResponse.ok) {\n      throw new Error('商品リスト取得失敗');\n    }\n\n    const data = await productsResponse.json();\n\n    return {\n      props: {\n        products: data.products,\n        currentPage: page,\n        totalPages: Math.ceil(data.totalCount / pageSize),\n        sortBy: validSort,\n        category: validCategory,\n      },\n      revalidate: 60,\n    };\n  } catch (error) {\n    console.error('Product list fetch failed:', error);\n    return {\n      props: {\n        products: [],\n        currentPage: page,\n        totalPages: 0,\n        sortBy: validSort,\n        category: validCategory,\n      },\n      revalidate: 10,\n    };\n  }\n};\n

\n

\n\n

\n

5. 注意点とベストプラクティス

\n\n

5-1. パフォーマンスとタイムアウト

\n\n

getServerSidePropsはリクエストのたびに実行されるため、パフォーマンスが重要です。以下の点に注意してください:

\n\n

    \n

  • タイムアウト設定:APIリクエストに必ずタイムアウトを設定します。Vercelの本番環境では10秒以上かかると自動的にタイムアウトします
  • \n

  • キャッシュの活用revalidateプロパティでISR(Incremental Static Regeneration)を使い、不必要なリクエストを削減します
  • \n

  • 不要なAPI呼び出しの削減:データベースクエリを最適化し、N+1問題を避けます
  • \n

\n\n

5-2. セキュリティ対策

\n\n

サーバーサイドで実行されるため、認証情報を安全に扱う必要があります:

\n\n

// ❌ 悪い例:秘密鍵がクライアントに公開される\nexport const getServerSideProps = async () => {\n  const response = await fetch(url, {\n    headers: {\n      'X-API-Key': process.env.NEXT_PUBLIC_API_KEY, // 危険!\n    },\n  });\n};\n\n// ✅ 良い例:秘密鍵はサーバーサイドのみで使用\nexport const getServerSideProps = async () => {\n  const response = await fetch(url, {\n    headers: {\n      'X-API-Key': process.env.API_SECRET_KEY, // サーバーサイドのみ\n    },\n  });\n};\n

\n\n

5-3. エラーハンドリング

\n\n

実務では、APIが必ず成功するとは限りません。適切なエラーハンドリングが重要です:

\n\n

export const getServerSideProps: GetServerSideProps = async (context) => {\n  try {\n    const response = await fetch(apiUrl, {\n      signal: AbortSignal.timeout(3000),\n    });\n\n    if (response.status === 503) {\n      // サービス一時停止時は、キャッシュを少し長く設定\n      return {\n        props: { fallbackData: true },\n        revalidate: 60,\n      };\n    }\n\n    if (!response.ok) {\n      throw new Error(`API error: ${response.status}`);\n    }\n\n    const data = await response.json();\n    return { props: { data }, revalidate: 10 };\n  } catch (error) {\n    // ネットワークエラーやタイムアウトの場合\n    if (error instanceof TypeError) {\n      console.error('Network error:', error);\n      return {\n        props: { fallbackData: true },\n        revalidate: 5,\n      };\n    }\n\n    // 予期しないエラー\n    console.error('Unexpected error:', error);\n    return {\n      notFound: true,\n    };\n  }\n};\n

\n\n

5-4. Cookie とセッション情報の扱い

\n\n

認証情報を安全に取得し、APIリクエストに含めます:

\n\n

export const getServerSideProps: GetServerSideProps = async (context) => {\n  const { req, res } = context;\n\n  // next-authを使用している場合\n  const session = await getSession({ req });\n\n  // または、手動でCookieから取得\n  const sessionToken = req.cookies.sessionToken;\n\n  if (!sessionToken) {\n    return {\n      redirect: {\n        destination: '/login',\n        permanent: false,\n      },\n    };\n  }\n\n  try {\n    const response = await fetch(apiUrl, {\n      headers: {\n        Cookie: `sessionToken=${sessionToken}`,\n      },\n    });\n\n    const data = await response.json();\n\n    return {\n      props: { data },\n      revalidate: 10,\n    };\n  } catch (error) {\n    return { notFound: true };\n  }\n};\n

\n\n

5-5. スケーラビリティの考慮

\n\n

アクセスが増加した場合のことを考慮します:

\n\n

    \n

  • キャッシング戦略:Redisなどのキャッシュレイヤーを導入し、同じリクエストへの重複アクセスを削減
  • \n

  • CDN活用:静的資産をCDNで配信し、レスポンス時間を短縮
  • \n

  • データベース最適化:インデックスの作成、クエリの最適化
  • \n

  • ISRの活用:可能な限りrevalidateを設定し、サーバーサイドレンダリングの回数を削減
  • \n

\n

\n\n

\n

6. 実務での落とし穴と対策

\n\n

6-1. 過度なAPI呼び出し

\n\n

getServerSidePropsはリクエストごとに実行されるため、不必要なAPI呼び出しは避けるべきです:

\n\n


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