Next.js getStaticProps を業務で使いこなす実装パターン集

React / Next.js

Next.js getStaticProps を業務で使いこなす実装パターン集

公開日:2024年 | 更新日:2024年


1. getStaticProps の基礎理解

Next.js の getStaticProps は、ビルド時に静的ファイルを生成する機能です。サーバーサイドで実行され、ページコンポーネントへ props を渡します。動的なデータが必要な場合は revalidate で ISR(Incremental Static Regeneration)を設定できます。

教科書的な説明ではなく、実務では以下の点が重要です:

  • ビルド時間の最適化
  • キャッシング戦略の設計
  • エラーハンドリングの実装
  • 外部APIの呼び出し管理
  • ステージング環境との連携

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

ユースケース1:ECサイトの商品一覧ページ

商品データベースから大量の商品情報を取得し、複数の一覧ページを生成する場合、全商品をビルド時に生成するのは非効率です。カテゴリごと、価格帯ごとにページを分割し、ISR を活用します。

ユースケース2:ブログメディアサイト

記事は定期的に更新されます。全記事をビルド時に生成しつつ、新規記事の公開に対応するため ISR を設定します。

ユースケース3:多言語対応サイト

複数の言語・地域向けにコンテンツを生成する必要があります。fallbackrevalidate を組み合わせて対応します。


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 メモリリークの防止

ビルド時にメモリ不足が発生することがあります:


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