Article

Next.js generateStaticParams実装で動的ルートを静的生成

高田晃太郎
Next.js generateStaticParams実装で動的ルートを静的生成

App Routerでの静的生成はSSR比でサーバCPU時間を大幅に減らせるというのは、公開されている資料や小規模な再現可能な検証でも観測されやすい傾向です[1]。同一トラフィック下でSSR(Server-Side Rendering)を静的配信に置き換えるだけで、p95のTTFB(最初のバイトまでの時間)の短縮と、NodeプロセスのCPU時間の削減が確認されるケースは少なくありません[1]。もちろん結果は環境依存ですが、CDN(コンテンツ配信網)経由で配布できる静的アセットへ落とし込めるかが、SLO(サービス目標)維持と原価最適化の分水嶺になるのは多くのプロダクトで共通です。Next.js 13以降のApp RouterではgetStaticPathsに相当する機能としてgenerateStaticParamsが提供され、動的セグメント(URLの可変部分)でもビルド時にURLを列挙して静的にプリレンダーできます[2]。この記事では設計の前提、実装の落とし穴、テスト戦略、運用最適化まで、CTO・リードが意思決定に使える粒度で整理します。対象はブログやドキュメント、ECのプロダクト詳細など、URLが明示的に列挙できる領域です。

なぜgenerateStaticParamsか:App Routerの設計意図とビジネス価値

App RouterのプリレンダーはCDN配信を前提に、平均応答ではなく尾部遅延(p95/p99)の削減に効く設計です[1]。generateStaticParamsはビルド時に動的セグメントの候補を列挙し、そのパスに対するpage.tsxのレンダリング結果を静的ファイルとして出力します[2]。dynamicParamsをfalseに設定すると列挙外のパスは404を即時返却し、スパイラルなSSR負荷や意図しないキャッシュミスを避けられます[3]。在庫や記事数が多く、すべてを毎回ビルドできない場合はISR(Incremental Static Regeneration)のrevalidateやタグベース無効化を併用し、フルリビルドのコストを払わずに鮮度を保つのが定石です[4,5]。

ビジネス観点では、プリレンダーによりサーバサイド計算がほぼゼロに近づくため、スパイク時のSLO劣化を抑えやすく、分散リソースのスケール戦略も単純化できます[1]。運用コスト面でも、SSR由来の関数実行やインスタンス常駐を削れる分だけ、単位トラフィックあたりの原価が読みやすくなります。

実装パターンと落とし穴:安全で速い静的生成を組み立てる

基本は、スラッグ(URL識別子)を列挙するデータ取得関数を用意し、それをgenerateStaticParamsで呼び出して配列を返す形です。取得側はNextのfetchにおけるキャッシュオプションとタグ付けを理解し[6]、障害時に不完全なパラメータを返さないように厳格なエラーハンドリングを入れます。ページ側ではnotFoundの扱い、動的要素のクライアント境界、そしてrevalidate(ISR)の設定を整えます。

基本実装:ブログの[slug]を静的生成(dynamicParams=false)

// app/blog/[slug]/page.tsx
import React from "react";
import { notFound } from "next/navigation";
import type { Metadata } from "next";
import { getPostBySlug, listAllSlugs } from "@/lib/posts";

export const dynamicParams = false; // 列挙外は即404
export const revalidate = 3600; // ISR: 1時間

export async function generateStaticParams() {
  const slugs = await listAllSlugs();
  return slugs.map((slug) => ({ slug }));
}

export async function generateMetadata({ params }: { params: { slug: string } }): Promise<Metadata> {
  const post = await getPostBySlug(params.slug);
  if (!post) return { title: "Not Found" };
  return { title: post.title, description: post.excerpt };
}

export default async function Page({ params }: { params: { slug: string } }) {
  const post = await getPostBySlug(params.slug);
  if (!post) notFound();
  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.body}</p>
    </article>
  );
}

listAllSlugsとgetPostBySlugは、フェッチとバリデーションを分離しておくとテストしやすくなります。スキーマ検証を通すことで不正データによるビルド失敗を早期に検出でき、障害時は明示的にエラーを投げてCIで止める方が安全です。ここでの「スキーマ」は外部APIからのレスポンス形状を定義する契約です。

// lib/posts.ts
import { z } from "zod";

const SlugSchema = z.string().min(1);
const PostSchema = z.object({
  slug: SlugSchema,
  title: z.string(),
  excerpt: z.string().optional().default(""),
  body: z.string()
});

type Post = z.infer<typeof PostSchema>;

const BASE = process.env.CMS_BASE_URL!;
const TOKEN = process.env.CMS_TOKEN!;

async function fetchJSON<T>(path: string, init: RequestInit = {}): Promise<T> {
  const res = await fetch(`${BASE}${path}`, {
    ...init,
    headers: { ...(init.headers || {}), Authorization: `Bearer ${TOKEN}` },
    cache: "force-cache",
    next: { revalidate: 3600, tags: ["posts"] }
  });
  if (!res.ok) {
    const text = await res.text().catch(() => "");
    throw new Error(`CMS fetch failed: ${res.status} ${text}`);
  }
  return res.json() as Promise<T>;
}

export async function listAllSlugs(): Promise<string[]> {
  const data = await fetchJSON<{ slug: string }[]>("/posts");
  const slugs = data.map((d) => d.slug).filter((s) => SlugSchema.safeParse(s).success);
  if (slugs.length === 0) throw new Error("No slugs returned");
  return slugs;
}

export async function getPostBySlug(slug: string): Promise<Post | null> {
  const data = await fetchJSON<unknown>(`/posts/${encodeURIComponent(slug)}`);
  const parsed = PostSchema.safeParse(data);
  return parsed.success ? parsed.data : null;
}

catch-allルートではパラメータ形状が配列になるため、列挙時とページ受取時の整合性を忘れないでください[2]。

// app/docs/[...segments]/page.tsx
import React from "react";

export const dynamicParams = false;

export async function generateStaticParams() {
  return [
    { segments: ["getting-started"] },
    { segments: ["api", "v1", "intro"] }
  ];
}

export default function Page({ params }: { params: { segments: string[] } }) {
  return <pre>{JSON.stringify(params.segments)}</pre>;
}

増分更新:タグ無効化とOn-Demand Revalidation

App Routerではタグでキャッシュを束ね、NextのrevalidateTag APIで無効化できます[5]。CMSのWebhookから叩けるルートを用意し、コンテンツ更新時に関連タグを無効化すれば、フルリビルドを避けつつ鮮度を担保できます[4,5]。タグは「どのデータがどのページに影響するか」を示すラベルだと捉えると運用が安定します。

// app/api/revalidate/route.ts
import { NextRequest, NextResponse } from "next/server";
import { revalidateTag } from "next/cache";

export async function POST(req: NextRequest) {
  const secret = req.nextUrl.searchParams.get("secret");
  if (secret !== process.env.REVALIDATE_SECRET) {
    return NextResponse.json({ ok: false }, { status: 401 });
  }
  const body = await req.json().catch(() => null) as { tag?: string } | null;
  const tag = body?.tag ?? "posts";
  revalidateTag(tag);
  return NextResponse.json({ ok: true, tag });
}

キャッシュセマンティクス:fetchのnextオプションを正しく使う

App Routerではfetchのcache/nextオプションがビルド時とリクエスト時のキャッシュ挙動を左右します[6]。静的生成するデータ取得は基本的にforce-cacheとrevalidateの併用が安全です[4,6]。一方で管理画面専用やドラフト表示のように鮮度が最優先のケースではno-storeを使い、プリレンダーの経路に混入しないよう条件分岐を明示してください[6]。

// lib/fetchers.ts
export async function fetchFresh<T>(url: string, init: RequestInit = {}): Promise<T> {
  const res = await fetch(url, { ...init, cache: "no-store" });
  if (!res.ok) throw new Error(`fetchFresh failed: ${res.status}`);
  return res.json() as Promise<T>;
}

export async function fetchCached<T>(url: string, init: RequestInit = {}): Promise<T> {
  const res = await fetch(url, { ...init, cache: "force-cache", next: { revalidate: 3600, tags: ["posts"] } });
  if (!res.ok) throw new Error(`fetchCached failed: ${res.status}`);
  return res.json() as Promise<T>;
}

テスト戦略とCI:generateStaticParamsを壊さない仕組み

静的生成は本番で壊れると全面的に404になるため、列挙関数とスキーマのテストは最優先です。ユニットテストではネットワーク層をモックしてスキーマ検証の成功・失敗を網羅し、E2Eでは既知パスの200と未知パスの404を検証します[3]。ビルド時エラーをCIで確実に検出するために、next buildのログにgenerateStaticParamsの失敗が現れたら即時失敗させるガードを入れておくと安心です。これにより、「列挙が空」「型崩れ」「外部APIの一時障害」など、事故の芽を早期に摘めます。

// __tests__/posts.test.ts (Jest)
import { listAllSlugs, getPostBySlug } from "@/lib/posts";

jest.mock("@/lib/posts", () => {
  const actual = jest.requireActual("@/lib/posts");
  return {
    ...actual,
    listAllSlugs: jest.fn(async () => ["hello", "world"]),
    getPostBySlug: jest.fn(async (slug: string) => slug === "hello" ? { slug, title: "Hello", excerpt: "", body: "hi" } : null)
  };
});

test("listAllSlugs returns non-empty array", async () => {
  const slugs = await listAllSlugs();
  expect(Array.isArray(slugs)).toBe(true);
  expect(slugs.length).toBeGreaterThan(0);
});

test("getPostBySlug returns null for missing", async () => {
  const post = await getPostBySlug("missing");
  expect(post).toBeNull();
});
// tests/e2e.spec.ts (Playwright)
import { test, expect } from "@playwright/test";

test("prebuilt slug responds 200", async ({ page }) => {
  await page.goto("/blog/hello");
  await expect(page.getByRole("heading", { level: 1 })).toHaveText(/Hello/);
});

test("unknown slug is 404", async ({ page }) => {
  const res = await page.request.get("/blog/__unknown__");
  expect(res.status()).toBe(404);
});

テストデータの増やし方はふたつの観点があります。ひとつはスキーマ検証の境界値を潰すことで、CMS側の変更に強い列挙関数を保つこと。もうひとつは運用上の事故を想定して、空配列や重複スラッグ、予約語などのケースを定期的に回すことです。加えて、buildジョブとE2Eジョブを分離し、build成功後に静的成果物を使ってE2Eを行うパイプラインにすると、本番挙動との乖離を減らせます。

パフォーマンスと運用:スケール、SLO、コストの最適点

生成件数が大きいとビルド時間がネックになります。列挙関数が外部APIを叩くなら、タイムアウトとリトライ、そしてページネーションを前提に設計してください。計測のためにビルド時ログへ出力するだけでも、ボトルネックの発見が早まります。以下のようにgenerateStaticParamsで計測しておくと、閾値を超えたときにCIで検知できます。

// app/products/[id]/page.tsx(抜粋:計測)
import React from "react";
import { listAllProductIds } from "@/lib/products";

export const dynamicParams = false;

export async function generateStaticParams() {
  console.time("listAllProductIds");
  const ids = await listAllProductIds();
  console.timeEnd("listAllProductIds");
  return ids.map((id) => ({ id }));
}

export default function Page() { return null; }

コスト面では、生成済みページのヒット率が高いほど、アプリサーバ側のCPU・メモリ消費は小さくなります。動的なパーソナライズが必要な部分はクライアント境界へ逃がし、静的なシェルに動的ウィジェットをはめ込む構成に寄せると尾部遅延の安定に効きます。ABテストやログ計測などの導入に際しても、プリレンダーされたマークアップを崩さずに差し込む方針を守るとキャッシュ効率が落ちにくくなります。

一方で、全てを静的化すれば良いわけでもありません。生成件数が爆発的に増えるケースでは、サブセットのみ静的化して、残りはdynamicParamsをtrueにしてリクエスト時生成に回す妥協も現実的です[2]。重要なのは、KPIに対してどのセグメントがSLOを支配しているかを観測し、静的化のROIが高い部分から適用する順序づけです。運用中は、応答ヘッダのx-next-cacheヒット状況やCDNログからキャッシュ効率を可視化して、設定のチューニングサイクルを回してください[6]。次のドキュメントも随時参照すると判断が早まります。公式のgenerateStaticParamsリファレンスは https://nextjs.org/docs/app/api-reference/functions/generate-static-params、キャッシュと再検証の詳細は https://nextjs.org/docs/app/building-your-application/caching、ルーティング仕様の全体像は https://nextjs.org/docs/app/building-your-application/routing です。

まとめ:静的生成をプロダクトの既定路線に

generateStaticParamsは、動的ルートを安全に、そして高速に届けるための強力なレバーです。列挙の正確さ、スキーマの堅牢さ、そして再検証の設計を押さえれば、アプリケーションの大半は静的に供給できるはずです。テストでは列挙関数と404の健全性を繰り返し検証し、CIでの検知を厳しくして本番の事故確率を下げましょう。運用では、どのセグメントを静的化すべきかをログから継続的に見直し、必要最小限の動的処理に絞る意思決定を続けてください。

まずは代表的な動的ルートを1つ選び、本文の基本パターンをそのまま適用してCDNヒット率とTTFBを観測するところから始めてみませんか。結果が得られたら、タグ無効化とE2Eテストを足し込み、静的生成をプロダクトの既定路線にする。それが、スケールとSLOとコストを同時に満たす最短経路です。

参考文献

  1. Vercel. Next.js: Server-Side Rendering vs. Static Generation. https://vercel.com/blog/nextjs-server-side-rendering-vs-static-generation
  2. Next.js Docs. generateStaticParams. https://nextjs.org/docs/app/api-reference/functions/generate-static-params
  3. Next.js Docs. Route Segment Config (dynamicParams). https://nextjs.org/docs/14/app/api-reference/file-conventions/route-segment-config
  4. Next.js Docs. Incremental Static Regeneration (ISR). https://nextjs.org/docs/app/building-your-application/data-fetching/incremental-static-regeneration
  5. Next.js Docs. revalidateTag. https://nextjs.org/docs/app/api-reference/functions/revalidateTag
  6. Next.js Docs. Fetching, Caching, and Revalidating. https://nextjs.org/docs/14/app/building-your-application/data-fetching/fetching-caching-and-revalidating