Next.js getStaticProps を業務で使いこなす実装パターン集
公開日:2024年 | 更新日:2024年
1. getStaticProps の基礎理解
Next.js の getStaticProps は、ビルド時に静的ファイルを生成する機能です。サーバーサイドで実行され、ページコンポーネントへ props を渡します。動的なデータが必要な場合は revalidate で ISR(Incremental Static Regeneration)を設定できます。
教科書的な説明ではなく、実務では以下の点が重要です:
- ビルド時間の最適化
- キャッシング戦略の設計
- エラーハンドリングの実装
- 外部APIの呼び出し管理
- ステージング環境との連携
2. 業務でのユースケース
ユースケース1:ECサイトの商品一覧ページ
商品データベースから大量の商品情報を取得し、複数の一覧ページを生成する場合、全商品をビルド時に生成するのは非効率です。カテゴリごと、価格帯ごとにページを分割し、ISR を活用します。
ユースケース2:ブログメディアサイト
記事は定期的に更新されます。全記事をビルド時に生成しつつ、新規記事の公開に対応するため ISR を設定します。
ユースケース3:多言語対応サイト
複数の言語・地域向けにコンテンツを生成する必要があります。fallback と revalidate を組み合わせて対応します。
3. 実装コード:実務パターン
パターン1:複数のAPI呼び出しと並列処理
// pages/products/index.tsx
import { GetStaticProps } from 'next';
import axios from 'axios';
import { ProductCard } from '@/components/ProductCard';
interface Product {
id: number;
name: string;
price: number;
category: string;
image: string;
stock: number;
}
interface Category {
id: number;
name: string;
slug: string;
}
interface PageProps {
products: Product[];
categories: Category[];
lastUpdated: string;
}
export const getStaticProps: GetStaticProps<PageProps> = async (context) => {
try {
// 複数のAPI呼び出しを並列実行
const [productsResponse, categoriesResponse] = await Promise.all([
axios.get(`${process.env.API_BASE_URL}/products?limit=100`, {
headers: {
'Authorization': `Bearer ${process.env.API_SECRET_KEY}`,
'X-Request-ID': `build-${Date.now()}`
},
timeout: 10000
}),
axios.get(`${process.env.API_BASE_URL}/categories`, {
headers: {
'Authorization': `Bearer ${process.env.API_SECRET_KEY}`
},
timeout: 5000
})
]);
const products: Product[] = productsResponse.data.items;
const categories: Category[] = categoriesResponse.data.items;
// データ検証
if (!Array.isArray(products) || products.length === 0) {
console.warn('No products found, using fallback data');
return {
notFound: true,
revalidate: 60 // 1分後に再生成試行
};
}
return {
props: {
products,
categories,
lastUpdated: new Date().toISOString()
},
// 10分ごとにバックグラウンド再生成
revalidate: 600,
// ISR対応 - オンデマンドで新規ページを生成
};
} catch (error) {
console.error('Failed to fetch products:', error);
// ビルド失敗を防ぐため、キャッシュからの復帰を許可
return {
revalidate: 30, // 30秒後に再試行
props: {
products: [],
categories: [],
lastUpdated: new Date().toISOString()
}
};
}
};
export default function ProductsPage({ products, categories, lastUpdated }: PageProps) {
return (
<>
<h1>商品一覧</h1>
<div>最終更新: {new Date(lastUpdated).toLocaleString('ja-JP')}</div>
<div className=\"grid grid-cols-1 md:grid-cols-3 gap-4\">
{products.map(product => (
<ProductCard key={product.id} product={product} />
))}
</div>
</>
);
}
パターン2:動的ルートとgetStaticPaths の組み合わせ
// pages/blog/[slug].tsx
import { GetStaticProps, GetStaticPaths } from 'next';
import { marked } from 'marked';
import { readFile, readdir } from 'fs/promises';
import path from 'path';
interface BlogPost {
slug: string;
title: string;
content: string;
htmlContent: string;
publishedAt: string;
author: string;
tags: string[];
}
interface PageProps {
post: BlogPost;
relatedPosts: Omit<BlogPost, 'htmlContent'>[];
}
// ビルド時に生成するパスを指定
export const getStaticPaths: GetStaticPaths = async () => {
try {
const postsDir = path.join(process.cwd(), 'content/blog');
const files = await readdir(postsDir);
const mdFiles = files.filter(f => f.endsWith('.md'));
const paths = mdFiles.map(file => ({
params: {
slug: file.replace('.md', '')
}
}));
return {
paths,
// 新しい記事はオンデマンドで生成
fallback: 'blocking'
};
} catch (error) {
console.error('Failed to generate paths:', error);
return {
paths: [],
fallback: 'blocking'
};
}
};
// 各記事ページのデータ取得
export const getStaticProps: GetStaticProps<PageProps> = async ({ params }) => {
try {
const { slug } = params as { slug: string };
const postsDir = path.join(process.cwd(), 'content/blog');
// メイン記事を取得
const filePath = path.join(postsDir, `${slug}.md`);
const fileContent = await readFile(filePath, 'utf-8');
// FrontMatterをパース(簡易版)
const lines = fileContent.split('\n');
let endOfFrontMatter = 0;
for (let i = 1; i < lines.length; i++) {
if (lines[i].startsWith('---')) {
endOfFrontMatter = i;
break;
}
}
const frontMatterStr = lines.slice(1, endOfFrontMatter).join('\n');
const contentStr = lines.slice(endOfFrontMatter + 1).join('\n');
// FrontMatterをオブジェクトに変換(実務ではfrontmatterライブラリ使用推奨)
const frontMatter: Record<string, any> = {};
frontMatterStr.split('\n').forEach(line => {
const [key, ...valueParts] = line.split(':');
if (key) {
frontMatter[key.trim()] = valueParts.join(':').trim();
}
});
// Markdown を HTML に変換
const htmlContent = await marked(contentStr);
// 関連記事を取得
const files = await readdir(postsDir);
const mdFiles = files
.filter(f => f.endsWith('.md') && f !== `${slug}.md`)
.slice(0, 3);
const relatedPosts = await Promise.all(
mdFiles.map(async (file) => {
const content = await readFile(path.join(postsDir, file), 'utf-8');
const fm = content.split('---')[1];
return {
slug: file.replace('.md', ''),
title: frontMatter['title'] || 'Untitled',
content: content.substring(0, 200),
publishedAt: frontMatter['date'] || new Date().toISOString(),
author: frontMatter['author'] || 'Unknown',
tags: (frontMatter['tags'] || '').split(',').map((t: string) => t.trim())
};
})
);
return {
props: {
post: {
slug,
title: frontMatter['title'] || 'Untitled',
content: contentStr,
htmlContent,
publishedAt: frontMatter['date'] || new Date().toISOString(),
author: frontMatter['author'] || 'Anonymous',
tags: (frontMatter['tags'] || '').split(',').map((t: string) => t.trim())
},
relatedPosts
},
revalidate: 3600 // 1時間ごとに再生成
};
} catch (error) {
console.error(`Failed to generate blog post: ${error}`);
return {
notFound: true,
revalidate: 60
};
}
};
export default function BlogPost({ post, relatedPosts }: PageProps) {
return (
<article>
<h1>{post.title}</h1>
<div className=\"metadata\">
<span>{post.author}</span>
<time>{new Date(post.publishedAt).toLocaleDateString('ja-JP')}</time>
</div>
<div className=\"tags\">
{post.tags.map(tag => (
<span key={tag} className=\"tag\">{tag}</span>
))}
</div>
<div dangerouslySetInnerHTML={{ __html: post.htmlContent }} />
<aside className=\"related-posts\">
<h3>関連記事</h3>
{relatedPosts.map(related => (
<a key={related.slug} href={`/blog/${related.slug}`}>
{related.title}
</a>
))}
</aside>
</article>
);
}
パターン3:環境別の設定とキャッシング戦略
// lib/staticPropsHelpers.ts
import axios, { AxiosInstance } from 'axios';
interface CacheConfig {
revalidate: number;
fallback: boolean;
}
// 環境別のキャッシュ戦略
const CACHE_STRATEGIES: Record<string, CacheConfig> = {
production: {
revalidate: 3600, // 本番環境では1時間
fallback: true
},
staging: {
revalidate: 300, // ステージング環境では5分
fallback: true
},
development: {
revalidate: 0, // 開発環境ではキャッシュなし
fallback: false
}
};
export function getCacheConfig(): CacheConfig {
const env = process.env.NEXT_PUBLIC_ENV || 'development';
return CACHE_STRATEGIES[env] || CACHE_STRATEGIES.development;
}
// 再試行ロジック付きのAPI呼び出し
export async function fetchWithRetry(
client: AxiosInstance,
url: string,
maxRetries: number = 3,
timeout: number = 5000
) {
let lastError;
for (let i = 0; i < maxRetries; i++) {
try {
const response = await client.get(url, { timeout });
return response.data;
} catch (error) {
lastError = error;
console.warn(`Retry attempt ${i + 1}/${maxRetries} for ${url}`);
// エクスポーネンシャルバックオフ
if (i < maxRetries - 1) {
await new Promise(resolve =>
setTimeout(resolve, Math.pow(2, i) * 1000)
);
}
}
}
throw lastError;
}
// キャッシュ層を含むデータ取得
const dataCache = new Map<string, { data: any; timestamp: number }>();
export async function getCachedData(
key: string,
fetcher: () => Promise<any>,
ttl: number = 300 // TTL: 秒
) {
const cached = dataCache.get(key);
const now = Date.now();
if (cached && now - cached.timestamp < ttl * 1000) {
return cached.data;
}
const data = await fetcher();
dataCache.set(key, { data, timestamp: now });
return data;
}
パターン4:複雑なデータ処理とデータベース連携
// pages/analytics/[period].tsx
import { GetStaticProps, GetStaticPaths } from 'next';
import { Pool } from 'pg';
interface AnalyticsData {
period: string;
totalRevenue: number;
totalOrders: number;
averageOrderValue: number;
topProducts: Array<{ productId: number; name: string; revenue: number }>;
dayByDayData: Array<{ date: string; revenue: number; orders: number }>;
}
// データベース接続プール(シングルトン)
let pool: Pool;
function getPool(): Pool {
if (!pool) {
pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 10,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
});
}
return pool;
}
export const getStaticPaths: GetStaticPaths = async () => {
// 過去12ヶ月分のパスを生成
const paths = [];
const now = new Date();
for (let i = 0; i < 12; i++) {
const date = new Date(now.getFullYear(), now.getMonth() - i, 1);
const period = date.toISOString().slice(0, 7); // YYYY-MM
paths.push({ params: { period } });
}
return {
paths,
fallback: 'blocking'
};
};
export const getStaticProps: GetStaticProps<{ analytics: AnalyticsData }> = async ({ params }) => {
const { period } = params as { period: string };
const pool = getPool();
let client;
try {
client = await pool.connect();
// トランザクション内で複数のクエリを実行
await client.query('BEGIN');
// 売上集計
const revenueResult = await client.query(`
SELECT
SUM(total_amount) as total_revenue,
COUNT(*) as total_orders,
AVG(total_amount) as average_order_value
FROM orders
WHERE DATE_TRUNC('month', created_at) = $1::date
AND status = 'completed'
`, [new Date(period + '-01')]);
// トップ商品
const topProductsResult = await client.query(`
SELECT
p.id as product_id,
p.name,
SUM(oi.quantity * oi.unit_price) as revenue
FROM order_items oi
JOIN products p ON p.id = oi.product_id
JOIN orders o ON o.id = oi.order_id
WHERE DATE_TRUNC('month', o.created_at) = $1::date
AND o.status = 'completed'
GROUP BY p.id, p.name
ORDER BY revenue DESC
LIMIT 10
`, [new Date(period + '-01')]);
// 日別データ
const dayByDayResult = await client.query(`
SELECT
DATE(created_at) as date,
SUM(total_amount) as revenue,
COUNT(*) as orders
FROM orders
WHERE DATE_TRUNC('month', created_at) = $1::date
AND status = 'completed'
GROUP BY DATE(created_at)
ORDER BY date ASC
`, [new Date(period + '-01')]);
await client.query('COMMIT');
const revenueData = revenueResult.rows[0];
const analyticsData: AnalyticsData = {
period,
totalRevenue: parseFloat(revenueData.total_revenue || '0'),
totalOrders: parseInt(revenueData.total_orders || '0', 10),
averageOrderValue: parseFloat(revenueData.average_order_value || '0'),
topProducts: topProductsResult.rows.map(row => ({
productId: row.product_id,
name: row.name,
revenue: parseFloat(row.revenue)
})),
dayByDayData: dayByDayResult.rows.map(row => ({
date: row.date,
revenue: parseFloat(row.revenue),
orders: parseInt(row.orders, 10)
}))
};
return {
props: { analytics: analyticsData },
revalidate: 86400 // 24時間ごと
};
} catch (error) {
console.error(`Failed to generate analytics for ${period}:`, error);
return {
notFound: true,
revalidate: 3600 // 1時間後に再試行
};
} finally {
if (client) {
client.release();
}
}
};
export default function AnalyticsPage({ analytics }: { analytics: AnalyticsData }) {
return (
<div>
<h1>分析: {analytics.period}</h1>
<div className=\"metrics\">
<div>
<h3>売上</h3>
<p>¥{analytics.totalRevenue.toLocaleString('ja-JP')}</p>
</div>
<div>
<h3>注文数</h3>
<p>{analytics.totalOrders}</p>
</div>
<div>
<h3>平均注文額</h3>
<p>¥{analytics.averageOrderValue.toLocaleString('ja-JP')}</p>
</div>
</div>
<h2>トップ商品</h2>
<table>
<thead>
<tr>
<th>商品</th>
<th>売上</th>
</tr>
</thead>
<tbody>
{analytics.topProducts.map(product => (
<tr key={product.productId}>
<td>{product.name}</td>
<td>¥{product.revenue.toLocaleString('ja-JP')}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
4. よくある応用パターン
応用1:多言語対応のgetStaticProps
// pages/[lang]/products/index.tsx
import { GetStaticProps, GetStaticPaths } from 'next';
const SUPPORTED_LANGUAGES = ['ja', 'en', 'zh'];
export const getStaticPaths: GetStaticPaths = async () => {
const paths = SUPPORTED_LANGUAGES.map(lang => ({
params: { lang }
}));
return {
paths,
fallback: false
};
};
export const getStaticProps: GetStaticProps = async ({ params }) => {
const { lang } = params as { lang: string };
if (!SUPPORTED_LANGUAGES.includes(lang)) {
return { notFound: true };
}
// 言語別のデータを取得
const data = await fetchProductsByLanguage(lang);
return {
props: {
products: data,
lang,
locale: getLocaleFromLang(lang)
},
revalidate: 1800
};
};
応用2:条件付きのプリレンダリング
// 環境変数でプリレンダリングを制御
export const getStaticProps: GetStaticProps = async () => {
// 本番環境のみプリレンダリング
if (process.env.NEXT_PUBLIC_ENV !== 'production') {
return {
props: {},
revalidate: 0 // キャッシュなし
};
}
const data = await fetchHeavyData();
return {
props: { data },
revalidate: 3600
};
};
応用3:Webhook トリガーによるオンデマンド再生成
// pages/api/revalidate.ts
import { NextApiRequest, NextApiResponse } from 'next';
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
// Webhook トークンの検証
if (req.query.secret !== process.env.REVALIDATE_SECRET) {
return res.status(401).json({ message: 'Invalid token' });
}
try {
const { path = '/products' } = req.body;
// 指定されたパスを再生成
await res.revalidate(path);
return res.json({
revalidated: true,
path,
timestamp: new Date().toISOString()
});
} catch (err) {
return res.status(500).json({
message: 'Error revalidating',
error: err instanceof Error ? err.message : 'Unknown error'
});
}
}
5. 注意点とベストプラクティス
5.1 ビルド時間の最適化
問題: getStaticProps で処理が重い場合、ビルドが非常に遅くなります。
解決策:
- API呼び出しを並列化(
Promise.all) - 大量ページ生成は
fallback: 'blocking'を活用 - データベース接続をプール化
- タイムアウト設定を短くして失敗を早期検出
// ❌ 悪い例:直列処理で遅い
const data1 = await fetch('/api/data1');
const data2 = await fetch('/api/data2');
const data3 = await fetch('/api/data3');
// ✅ 良い例:並列処理で高速
const [data1, data2, data3] = await Promise.all([
fetch('/api/data1'),
fetch('/api/data2'),
fetch('/api/data3')
]);
5.2 エラーハンドリング
API が失敗してもビルド全体を失敗させないことが重要です:
export const getStaticProps: GetStaticProps = async () => {
try {
const data = await fetchCriticalData();
return {
props: { data },
revalidate: 3600
};
} catch (error) {
// ビルドを失敗させずに notFound を返すか、フォールバックデータを使用
console.error('Critical data fetch failed:', error);
// 方法1:404 ページを表示
return { notFound: true, revalidate: 60 };
// 方法2:フォールバックデータを使用
// return {
// props: { data: FALLBACK_DATA },
// revalidate: 30 // 早めに再試行
// };
}
};
5.3 メモリリークの防止
ビルド時にメモリ不足が発生することがあります:

