Next.js SSRの実務パターン解説|業務で使える実装コード集
Next.jsでサーバーサイドレンダリング(SSR)を導入する際、教科書通りの実装では対応できない業務上の課題が多く発生します。本記事では、実際の開発現場で直面する実装パターンと解決策を、具体的なコード例を交えて解説します。
1. Next.jsのSSRに関する簡易的な解説
Next.jsのSSRは、サーバー側でReactコンポーネントをHTMLにレンダリングして、クライアントに初期状態を含めた完全なHTMLを送信する技術です。従来のクライアントサイドレンダリング(CSR)と異なり、以下のメリットがあります。
- SEO最適化: 検索エンジンクローラーが完全なHTMLを取得できるため、インデックス効率が向上
- 初期表示速度: ブラウザでのJavaScript実行待機時間がなくなり、First Contentful Paint(FCP)が高速化
- OGPタグ: ページごとに異なるOGPメタデータを動的に生成可能
Next.jsでは、ページコンポーネントにgetServerSideProps関数を実装することで、リクエスト時にサーバー側でデータフェッチやロジック処理を実行できます。
2. 業務でのユースケース
2-1. ユースケース1:APIからのデータ取得とエラーハンドリング
ECサイトやニュースサイトでは、ページ読み込み時に複数のAPIからデータを取得し、取得失敗時にはフォールバック表示を用意する必要があります。例えば、商品詳細ページでは商品情報とレビュー、在庫情報を別々のエンドポイントから取得する場合、タイムアウト対応とリトライロジックが必須です。
2-2. ユースケース2:認証情報の検証と権限制御
会員限定ページやダッシュボードでは、リクエストのクッキーから認証トークンを取得し、サーバー側で有効性を検証します。トークンが無効な場合はログインページへリダイレクトするといった条件分岐が必要です。
2-3. ユースケース3:キャッシング戦略の実装
ブログ記事や商品情報など頻繁に変更されないデータは、キャッシュサーバーに保存して、リクエストごとのDB参照を減らす必要があります。一方、在庫情報のように変動性の高いデータはキャッシュを避ける判断も重要です。
2-4. ユースケース4:動的なOGPメタデータの生成
SNS共有時に正確な記事タイトルや画像を表示するため、ページごとに異なるOGPタグを生成する必要があります。特にユーザー生成コンテンツ(UGC)サイトでは、コンテンツIDに応じた動的なメタデータが不可欠です。
3. 実装コード
3-1. 基本的なSSR実装(APIエラーハンドリング付き)
// pages/products/[id].tsx
import { GetServerSideProps } from 'next';
import { useRouter } from 'next/router';
import Head from 'next/head';
interface Product {
id: string;
name: string;
price: number;
description: string;
imageUrl: string;
}
interface Review {
id: string;
rating: number;
comment: string;
author: string;
}
interface Props {
product: Product | null;
reviews: Review[];
error: string | null;
}
export default function ProductPage({ product, reviews, error }: Props) {\n const router = useRouter();\n\n if (router.isFallback) {\n return ページを読み込み中...;\n }\n\n if (error || !product) {\n return (\n \n 申し訳ありません
\n 商品情報を取得できませんでした。
\n {error}
\n \n );\n }\n\n return (\n <>\n \n {product.name} \n \n \n \n \n \n {product.name}
\n
\n ¥{product.price.toLocaleString()}
\n {product.description}
\n \n レビュー ({reviews.length}件)
\n {reviews.length > 0 ? (\n \n {reviews.map((review) => (\n - \n
★{review.rating}
\n {review.comment}
\n by {review.author}
\n \n ))}\n
\n ) : (\n レビューはまだありません
\n )}\n \n \n >\n );\n}\n\nexport const getServerSideProps: GetServerSideProps = async ({\n params,\n req,\n}) => {\n const productId = params?.id as string;\n const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';\n\n try {\n // 商品情報の取得(5秒のタイムアウト付き)\n const productController = new AbortController();\n const productTimeout = setTimeout(() => productController.abort(), 5000);\n\n const productRes = await fetch(`${API_BASE_URL}/api/products/${productId}`, {\n signal: productController.signal,\n headers: {\n 'Content-Type': 'application/json',\n Cookie: req.headers.cookie || '',\n },\n });\n\n clearTimeout(productTimeout);\n\n if (!productRes.ok) {\n if (productRes.status === 404) {\n return {\n notFound: true,\n };\n }\n throw new Error(`Product API error: ${productRes.status}`);\n }\n\n const product = (await productRes.json()) as Product;\n\n // レビュー情報の取得(失敗時は空配列を返す)\n let reviews: Review[] = [];\n try {\n const reviewController = new AbortController();\n const reviewTimeout = setTimeout(() => reviewController.abort(), 3000);\n\n const reviewRes = await fetch(\n `${API_BASE_URL}/api/products/${productId}/reviews`,\n {\n signal: reviewController.signal,\n headers: {\n 'Content-Type': 'application/json',\n },\n }\n );\n\n clearTimeout(reviewTimeout);\n\n if (reviewRes.ok) {\n reviews = await reviewRes.json();\n }\n } catch (reviewError) {\n console.warn('Failed to fetch reviews:', reviewError);\n // レビュー取得失敗は全体エラーとしない\n }\n\n return {\n props: {\n product,\n reviews,\n error: null,\n },\n revalidate: 60, // ISRで60秒ごとに再検証\n };\n } catch (error) {\n console.error('getServerSideProps error:', error);\n\n return {\n props: {\n product: null,\n reviews: [],\n error:\n error instanceof Error\n ? error.message\n : '予期しないエラーが発生しました',\n },\n revalidate: 10, // エラー時は10秒後に再試行\n };\n }\n};
3-2. 認証情報の検証と権限制御
// pages/dashboard/analytics.tsx\nimport { GetServerSideProps } from 'next';\nimport { parseCookies } from 'nookies';\n\ninterface Analytics {\n userId: string;\n totalSales: number;\n totalOrders: number;\n conversionRate: number;\n}\n\ninterface Props {\n analytics: Analytics;\n}\n\nexport default function AnalyticsDashboard({ analytics }: Props) {\n return (\n \n アナリティクスダッシュボード
\n \n \n 売上
\n ¥{analytics.totalSales.toLocaleString()}
\n \n \n 注文数
\n {analytics.totalOrders}
\n \n \n コンバージョン率
\n {(analytics.conversionRate * 100).toFixed(2)}%
\n \n \n \n );\n}\n\ninterface TokenPayload {\n userId: string;\n exp: number;\n}\n\nfunction verifyToken(token: string, secret: string): TokenPayload | null {\n try {\n // 実務ではjsonwebtokenライブラリを使用\n const jwt = require('jsonwebtoken');\n const decoded = jwt.verify(token, secret) as TokenPayload;\n return decoded;\n } catch (error) {\n return null;\n }\n}\n\nexport const getServerSideProps: GetServerSideProps = async ({\n req,\n res,\n}) => {\n const cookies = parseCookies({ req });\n const token = cookies.authToken;\n const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key';\n\n if (!token) {\n return {\n redirect: {\n destination: '/login?redirect=/dashboard/analytics',\n permanent: false,\n },\n };\n }\n\n const decoded = verifyToken(token, JWT_SECRET);\n\n if (!decoded || decoded.exp < Date.now() / 1000) {\n // クッキーをクリア\n res.setHeader('Set-Cookie', 'authToken=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 UTC;');\n\n return {\n redirect: {\n destination: '/login?redirect=/dashboard/analytics&expired=true',\n permanent: false,\n },\n };\n }\n\n try {\n const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';\n\n const analyticsRes = await fetch(\n `${API_BASE_URL}/api/users/${decoded.userId}/analytics`,\n {\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${token}`,\n },\n }\n );\n\n if (!analyticsRes.ok) {\n throw new Error('Failed to fetch analytics');\n }\n\n const analytics = (await analyticsRes.json()) as Analytics;\n\n return {\n props: {\n analytics,\n },\n revalidate: 300, // 5分ごとに再検証\n };\n } catch (error) {\n console.error('Failed to fetch analytics:', error);\n\n return {\n props: {\n analytics: {\n userId: decoded.userId,\n totalSales: 0,\n totalOrders: 0,\n conversionRate: 0,\n },\n },\n revalidate: 60,\n };\n }\n};
3-3. キャッシング戦略の実装(Redis連携)
// pages/blog/[slug].tsx\nimport { GetServerSideProps } from 'next';\nimport { createClient } from 'redis';\nimport Head from 'next/head';\n\ninterface BlogPost {\n id: string;\n slug: string;\n title: string;\n content: string;\n author: string;\n publishedAt: string;\n updatedAt: string;\n isMutable: boolean; // 変動性の高いコンテンツかどうか\n}\n\ninterface Props {\n post: BlogPost;\n}\n\nexport default function BlogPost({ post }: Props) {\n return (\n <>\n \n {post.title} \n \n \n \n \n \n \n \n {post.title}
\n by {post.author} | {new Date(post.publishedAt).toLocaleDateString()}
\n \n {post.content}\n \n \n >\n );\n}\n\nclass CacheManager {\n private client: ReturnType | null = null;\n private isConnected = false;\n\n async initialize() {\n if (this.isConnected) return;\n\n try {\n this.client = createClient({\n host: process.env.REDIS_HOST || 'localhost',\n port: parseInt(process.env.REDIS_PORT || '6379'),\n });\n\n await this.client.connect();\n this.isConnected = true;\n } catch (error) {\n console.error('Redis connection failed:', error);\n this.client = null;\n }\n }\n\n async get(key: string): Promise {\n if (!this.client) return null;\n\n try {\n const value = await this.client.get(key);\n return value ? JSON.parse(value) : null;\n } catch (error) {\n console.error(`Cache get error for key ${key}:`, error);\n return null;\n }\n }\n\n async set(key: string, value: any, ttl: number): Promise {\n if (!this.client) return false;\n\n try {\n await this.client.setEx(key, ttl, JSON.stringify(value));\n return true;\n } catch (error) {\n console.error(`Cache set error for key ${key}:`, error);\n return false;\n }\n }\n\n async disconnect() {\n if (this.client && this.isConnected) {\n await this.client.disconnect();\n this.isConnected = false;\n }\n }\n}\n\nconst cacheManager = new CacheManager();\n\nexport const getServerSideProps: GetServerSideProps = async ({\n params,\n}) => {\n const slug = params?.slug as string;\n const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';\n const CACHE_KEY = `blog:post:${slug}`;\n\n await cacheManager.initialize();\n\n try {\n // キャッシュから取得を試みる\n const cachedPost = await cacheManager.get(CACHE_KEY);\n if (cachedPost) {\n return {\n props: { post: cachedPost },\n revalidate: 600, // キャッシュヒット時は長めのISR\n };\n }\n\n // APIからブログ記事を取得\n const postRes = await fetch(`${API_BASE_URL}/api/blog/${slug}`, {\n headers: { 'Content-Type': 'application/json' },\n });\n\n if (!postRes.ok) {\n return { notFound: true };\n }\n\n const post = (await postRes.json()) as BlogPost;\n\n // キャッシュに保存(変動性による使い分け)\n const cacheTTL = post.isMutable ? 300 : 3600; // 変動性高い→5分、低い→1時間\n await cacheManager.set(CACHE_KEY, post, cacheTTL);\n\n return {\n props: { post },\n revalidate: cacheTTL,\n };\n } catch (error) {\n console.error('Error fetching blog post:', error);\n return { notFound: true };\n } finally {\n await cacheManager.disconnect();\n }\n};
3-4. 複数APIの並列リクエストと部分的フォールバック
// pages/home.tsx\nimport { GetServerSideProps } from 'next';\n\ninterface FeaturedProduct {\n id: string;\n name: string;\n price: number;\n}\n\ninterface Promotion {\n id: string;\n title: string;\n imageUrl: string;\n}\n\ninterface Props {\n featuredProducts: FeaturedProduct[];\n promotions: Promotion[];\n recommendedProducts: FeaturedProduct[];\n isPartialError: boolean;\n}\n\nexport default function HomePage({\n featuredProducts,\n promotions,\n recommendedProducts,\n isPartialError,\n}: Props) {\n return (\n \n {isPartialError && (\n \n 一部のコンテンツが表示されない可能性があります\n \n )}\n\n {promotions.length > 0 && (\n \n キャンペーン
\n \n {promotions.map((promo) => (\n \n
\n {promo.title}
\n \n ))}\n \n \n )}\n\n {featuredProducts.length > 0 && (\n \n おすすめ商品
\n \n {featuredProducts.map((product) => (\n \n {product.name}
\n ¥{product.price.toLocaleString()}
\n \n ))}\n \n \n )}\n\n {recommendedProducts.length > 0 && (\n \n あなたへのおすすめ
\n \n {recommendedProducts.map((product) => (\n \n {product.name}
\n ¥{product.price.toLocaleString()}
\n \n ))}\n \n \n )}\n \n );\n}\n\ninterface FetchResult {\n data: T | null;\n error: Error | null;\n}\n\nasync function fetchWithFallback(\n url: string,\n timeout: number = 5000\n): Promise> {\n const controller = new AbortController();\n const timeoutId = setTimeout(() => controller.abort(), timeout);\n\n try {\n const response = await fetch(url, {\n signal: controller.signal,\n headers: { 'Content-Type': 'application/json' },\n });\n\n clearTimeout(timeoutId);\n\n if (!response.ok) {\n throw new Error(`HTTP ${response.status}`);\n }\n\n const data = await response.json();\n return { data, error: null };\n } catch (error) {\n clearTimeout(timeoutId);\n return {\n data: null,\n error: error instanceof Error ? error : new Error('Unknown error'),\n };\n }\n}\n\nexport const getServerSideProps: GetServerSideProps = async () => {\n const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';\n\n // 3つのAPIを並列実行\n const [promotionResult, featuredResult, recommendedResult] = await Promise.all(\n [\n fetchWithFallback(`${API_BASE_URL}/api/promotions`, 3000),\n fetchWithFallback(\n `${API_BASE_URL}/api/products/featured`,\n 5000\n ),\n fetchWithFallback(\n `${API_BASE_URL}/api/products/recommended`,\n 5000\n ),\n ]\n );\n\n const isPartialError =\n promotionResult.error !== null ||\n featuredResult.error !== null ||\n recommendedResult.error !== null;\n\n if (isPartialError) {\n console.warn('Partial API failures on home page:', {\n promotions: promotionResult.error?.message,\n featured: featuredResult.error?.message,\n recommended: recommendedResult.error?.message,\n });\n }\n\n return {\n props: {\n promotions: promotionResult.data || [],\n featuredProducts: featuredResult.data || [],\n recommendedProducts: recommendedResult.data || [],\n isPartialError,\n },\n revalidate: 60,\n };\n};
4. よくある応用パターン
4-1. クエリパラメータに基づいた条件分岐
検索結果ページやフィルタ機能を持つページでは、URLのクエリパラメータに基づいて異なるAPIエンドポイントを呼び出す必要があります。
// pages/products.tsx\nimport { GetServerSideProps } from 'next';\n\ninterface Props {\n products: any[];\n currentPage: number;\n totalPages: number;\n query: string;\n}\n\nexport const getServerSideProps: GetServerSideProps = async ({\n query,\n}) => {\n const searchQuery = (query.q as string) || '';\n const page = parseInt((query.page as string) || '1', 10);\n const category = (query.category as string) || '';\n const sortBy = (query.sort as string) || 'popularity';\n\n const params = new URLSearchParams({\n q: searchQuery,\n page: page.toString(),\n ...(category && { category }),\n sort: sortBy,\n });\n\n const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';\n\n try {\n const res = await fetch(`${API_BASE_URL}/api/products/search?${params}`);\n const data = await res.json();\n\n return {\n props: {\n products: data.products,\n currentPage: page,\n totalPages: data.totalPages,\n query: searchQuery,\n },\n revalidate: 300,\n };\n } catch (error) {\n return {\n notFound: true,\n };\n }\n};
4-2. ローカライゼーション対応
複数言語や地域に対応したアプリケーションでは、Accept-Languageヘッダーやクエリパラメータから言語情報を取得し、適切なAPIエンドポイントを選択します。
// pages/products/[id].tsx (i18n対応版)\nimport { GetServerSideProps } from 'next';\n\ninterface Props {\n product: any;\n locale: string;\n}\n\nexport const getServerSideProps: GetServerSideProps = async ({\n params,\n req,\n locales,\n defaultLocale,\n}) => {\n const productId = params?.id as string;\n // URLから言語を取得、または Accept-Language ヘッダーから推測\n const locale = (params?.locale as string) || defaultLocale || 'en';\n const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';\n\n try {\n const res = await fetch(\n `${API_BASE_URL}/api/products/${productId}?locale=${locale}`,\n {\n headers: {\n 'Accept-Language': req.headers['accept-language'] || 'en',\n },\n }\n );\n\n if (!res.ok) {\n return { notFound: true };\n }\n\n const product = await res.json();\n\n return {\n props: {\n product,\n locale,\n },\n revalidate: 3600,\n };\n } catch (error) {\n return { notFound: true };\n }\n};
4-3. APIレスポンス型の共有化
フロントエンドとバックエンドでAPI型定義を共有することで、型安全性を高めます。実務では別パッケージで管理することが多いです。

