generateStaticParams で全記事を事前ビルド — Next.js 静的サイト生成の仕組み

Next.js で posts/[slug] のような動的ルートを静的ファイルとして書き出すには、generateStaticParams で生成対象のパスを列挙する必要がある。 この記事では、ブログの記事ページを例に取りながら、この関数の実装パターンと output: "export" 時の挙動を整理する。

なぜ generateStaticParams が必要か

Next.js の動的ルートセグメント([slug] など)は、ビルド時点で「どの値が存在するか」をフレームワークが知ることができない。 そのため、静的エクスポートをする際には generateStaticParams で全パターンを列挙し、対応する HTML を事前に生成しておく必要がある。

posts/
  hello-world.md   →  /posts/hello-world
  next-tips.md     →  /posts/next-tips

この例では hello-worldnext-tips という2つのスラッグを、ビルド時に Next.js へ伝える必要がある。

基本的な実装

// src/app/posts/[slug]/page.tsx

import { getPostData, getAllSlugs } from "@/app/lib/functions";

// ビルド時に生成するパスを列挙する
export async function generateStaticParams() {
  const slugs = await getAllSlugs();
  return slugs.map((slug) => ({ slug }));
}

generateStaticParams{ slug: string }[] の配列を返す関数だ。 slug の名前は動的セグメントのフォルダ名([slug])と一致させる必要がある。

getAllSlugs の実装

// src/app/lib/functions.ts

import fs from "fs";
import path from "path";

const postsDirectory = path.join(process.cwd(), "posts");

export function getAllSlugs(): string[] {
  const filenames = fs.readdirSync(postsDirectory);
  return filenames
    .filter((name) => name.endsWith(".md"))
    .map((name) => name.replace(/\.md$/, ""));
}

posts/ ディレクトリのファイル名から .md を取り除いたものがスラッグになる。 ファイル名と URL スラッグを 1:1 対応させるのが、このブログのシンプルな設計方針だ。

ページコンポーネントとの連携

generateStaticParams が返した各スラッグは、ページコンポーネントの params プロパティで受け取れる。

Next.js 15 以降では paramsPromise 型に変更されたため、await して使う必要がある。

// src/app/posts/[slug]/page.tsx(Next.js 15 以降)

type Props = {
  params: Promise<{ slug: string }>;
};

export default async function PostPage({ params }: Props) {
  const { slug } = await params; // Next.js 15 以降は await が必要
  const post = await getPostData(slug);

  return (
    <article>
      <h1>{post.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: post.contentHtml }} />
    </article>
  );
}

Next.js 14 以前では params は同期的に受け取れた。

// Next.js 14 以前のパターン
type Props = {
  params: { slug: string };
};

export default async function PostPage({ params }: Props) {
  const { slug } = params; // await 不要
  const post = await getPostData(slug);
  // ...
}

generateStaticParams 関数自体の書き方はバージョン間で変わらない。変更が必要なのはページコンポーネント側の型定義と await の有無だ。


`params.slug` は `generateStaticParams` が返した配列の各要素の `slug` プロパティと対応している。

## output: "export" との関係

Next.js の `next.config.js` で `output: "export"` を設定すると、`npm run build` 実行時にすべてのページが HTML ファイルとして書き出される。

```javascript
// next.config.js
const nextConfig = {
  output: "export",
};

このとき、動的ルートに対して generateStaticParams が定義されていないとビルドが失敗する。

Error: Page "/posts/[slug]" is missing "generateStaticParams()" ...

逆に言えば、generateStaticParams さえ正しく実装されていれば、動的ルートも完全な静的ファイルとして出力できる。

dynamicParams で未知のパスをコントロールする

output: "export" では関係ないが、サーバーありの構成では dynamicParams 設定で未定義パスの挙動を制御できる。

// generateStaticParams に含まれないパスを 404 にする
export const dynamicParams = false;

export async function generateStaticParams() {
  // ...
}

dynamicParams = false にすると generateStaticParams で返したパス以外はすべて 404 になる(Pages Router の fallback: false 相当)。

複数のセグメントを持つルート

タグページのように [tag]/[page] など複数のセグメントがある場合は、すべての組み合わせを列挙する。

// src/app/tags/[tag]/[page]/page.tsx

export async function generateStaticParams() {
  const posts = await getPostData();
  const allTags = [...new Set(posts.flatMap((p) => p.tags))];

  const params: { tag: string; page: string }[] = [];

  for (const tag of allTags) {
    const filtered = posts.filter((p) => p.tags.includes(tag));
    const totalPages = Math.ceil(filtered.length / POSTS_PER_PAGE);

    for (let i = 1; i <= totalPages; i++) {
      params.push({ tag, page: String(i) });
    }
  }

  return params;
}

ネストしたループで全組み合わせを生成し、フラットな配列として返す。

業務での使いどころ

generateStaticParams が真価を発揮するのは、外部データを元にページを生成したいときだ。

CMSの記事一覧からパスを生成する例:

export async function generateStaticParams() {
  // Headless CMS の API を呼び出してスラッグ一覧を取得
  const res = await fetch("https://api.example.com/posts?fields=slug");
  const posts: { slug: string }[] = await res.json();

  return posts.map(({ slug }) => ({ slug }));
}

generateStaticParams 内では fetch が使えるため、Contentful や microCMS などの Headless CMS と組み合わせると、CMS 上で記事を管理しながら静的ファイルとして配信するアーキテクチャが実現できる。

データベースの ID を使う例:

export async function generateStaticParams() {
  const db = await getDatabase();
  const articles = await db.select("id").from("articles").where("published", true);

  return articles.map(({ id }) => ({ id: String(id) }));
}

公開済みの記事のみパスを生成し、非公開記事の HTML は出力しない、という制御もシンプルに書ける。

ハマりやすいポイント

セグメント名の不一致

generateStaticParams が返すオブジェクトのキーと、フォルダ名のセグメント名が一致しないとエラーになる。

フォルダ: app/posts/[postId]/page.tsx
                      ^^^^^^
返すキー: { postId: "..." }  ← postId に合わせる(slug ではない)

数値スラッグは文字列に変換する

ページ番号など数値をスラッグに使う場合、必ず String() で文字列に変換する。オブジェクトの値は常に string 型でなければならない。

// NG: { page: 1 }
// OK: { page: "1" }
params.push({ page: String(i) });

ファイル名に含まれる拡張子

fs.readdirSync() はファイル名をそのまま返すため、.md 拡張子を除去し忘れるとスラッグが hello-world.md になってしまう。

// 必ず拡張子を除去する
.map((name) => name.replace(/\.md$/, ""))

generateStaticParams は Server Component 専用

generateStaticParams はサーバーサイドでのみ実行される。クライアントコンポーネント("use client" を持つファイル)に書いても動作しない。ページコンポーネント(page.tsx)に定義するのが基本だ。

Pages Router の getStaticPaths との比較

App Router の generateStaticParams と、Pages Router の getStaticPaths は役割が同じだが、書き方が異なる。

項目Pages Router (getStaticPaths)App Router (generateStaticParams)
関数名getStaticPathsgenerateStaticParams
戻り値の形式{ paths: [{ params: { slug: "..." } }], fallback: false }[{ slug: "..." }]
fallback の指定必要不要(output: "export" では常に false 相当)
配置場所pages/posts/[slug].tsxapp/posts/[slug]/page.tsx

App Router 版の方が戻り値がシンプルで、fallback の概念がないぶん記述量が減っている。 output: "export" では動的なフォールバックは使えないため、App Router への移行時に迷うことは少ない。

まとめ

  • generateStaticParams は動的ルートの全パスをビルド時に列挙する関数
  • output: "export" を使う場合は定義が必須。なければビルドが失敗する
  • 返すオブジェクトのキーはフォルダの [セグメント名] と一致させる
  • 数値は必ず String() で文字列に変換する
  • Headless CMS や DB と組み合わせると、外部データを静的ファイルとして配信できる
  • Pages Router の getStaticPaths と役割は同じだが、戻り値の形式がよりシンプル