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-world と next-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 以降では params が Promise 型に変更されたため、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) |
|---|---|---|
| 関数名 | getStaticPaths | generateStaticParams |
| 戻り値の形式 | { paths: [{ params: { slug: "..." } }], fallback: false } | [{ slug: "..." }] |
fallback の指定 | 必要 | 不要(output: "export" では常に false 相当) |
| 配置場所 | pages/posts/[slug].tsx | app/posts/[slug]/page.tsx |
App Router 版の方が戻り値がシンプルで、fallback の概念がないぶん記述量が減っている。
output: "export" では動的なフォールバックは使えないため、App Router への移行時に迷うことは少ない。
まとめ
generateStaticParamsは動的ルートの全パスをビルド時に列挙する関数output: "export"を使う場合は定義が必須。なければビルドが失敗する- 返すオブジェクトのキーはフォルダの
[セグメント名]と一致させる - 数値は必ず
String()で文字列に変換する - Headless CMS や DB と組み合わせると、外部データを静的ファイルとして配信できる
- Pages Router の
getStaticPathsと役割は同じだが、戻り値の形式がよりシンプル