言語を切り替える
テーマを切り替える

Next.js 動的ルーティング完全攻略:基礎から Type-Safe な実装まで

はじめに

先週、Next.js プロジェクトのリファクタリング中に、私は発狂寸前でした。
ドキュメント通りに動的ルートを設定したはずなのに、何度クリックしても「404 Not Found」。コンソールには何のエラーも出ていません。

原因は、Next.js 14 App Router での「パラメータ取得方法の変更」でした。私は無意識に Pages Router 時代の useRouter を使って URL パラメータを取ろうとしていたのです。

Next.js のルーティングシステムは強力ですが、App Router でファイルシステムベースのルーティングが刷新されたため、混乱しやすいポイントが多々あります。「動的セグメント」「Catch-all」「Optional Catch-all」……これらをどう使い分けるべきか? そして、TypeScript でどうやって型安全にするのか?

この記事では、Next.js の動的ルーティングを基礎から応用まで解説します。Pages Router からの移行で躓きやすいポイントや、私が実際に使っている「型安全なパラメータ処理」の実装パターンも紹介します。

第一章:動的ルーティングの基礎

「動的ルート」とは?

例えばブログサイトを想像してください。記事ごとに /blog/hello-world/blog/nextjs-guide といった URL が必要です。これら一つ一つに対してファイルを作るのは不可能です。
そこで登場するのが動的ルートです。一つのファイルで /blog/* 以下のすべてのアクセスを処理します。

基本的な実装:[slug]

App Router では、フォルダ名に角括弧 [] を使うことで動的セグメントを作成します。

app/
├── blog/
│   └── [slug]/
│       └── page.tsx    ← これが動的ルート

この page.tsx は、/blog/foo/blog/bar にマッチします。
パラメータは props.params として受け取ります。

// app/blog/[slug]/page.tsx
export default async function BlogPost({
  params
}: {
  params: { slug: string }
}) {
  return (
    <div>
      <h1>記事スラッグ: {params.slug}</h1>
    </div>
  )
}

Pages Router との違い

  • Pages Router: useRouter().query.slug または getServerSideProps の引数
  • App Router: コンポーネントの props.params(Server Component なので Hooks は使えません)

実践例:商品詳細ページ

/products/123 のようなページを作る場合:

// app/products/[id]/page.tsx
// 実際のデータフェッチ関数(モック)
async function getProduct(id: string) {
  const products = [
    { id: '1', name: 'TypeScript 入門', price: 2980 },
    { id: '2', name: 'React 実践ガイド', price: 3980 }
  ]
  return products.find(p => p.id === id)
}

export default async function ProductPage({
  params
}: {
  params: { id: string }
}) {
  // サーバーサイドで直接データを取得
  const product = await getProduct(params.id)

  if (!product) {
    return <div>商品が見つかりません</div>
  }

  return (
    <article>
      <h1>{product.name}</h1>
      <p>¥{product.price}</p>
    </article>
  )
}

もし「商品が見つからない」場合に 404 ページを表示させたいなら、Next.js の notFound() 関数を使います。

import { notFound } from 'next/navigation'

// ...
if (!product) {
  notFound() // これを実行すると app/not-found.tsx が表示される
}

第二章:複雑なパスを扱う Catch-All

深い階層に対応する:[...slug]

ドキュメントサイトなどで、/docs/getting-started/installation のように階層が深くなる場合があります。
普通の [slug] では1階層しかマッチしませんが、Catch-All セグメント [...slug] を使うと、その下のすべての階層を配列として受け取ることができます。

app/
├── docs/
│   └── [...slug]/
│       └── page.tsx

マッチング例:

  • /docs/aparams.slug = ['a']
  • /docs/a/bparams.slug = ['a', 'b']
  • /docs/a/b/cparams.slug = ['a', 'b', 'c']
// app/docs/[...slug]/page.tsx
export default function DocsPage({
  params
}: {
  params: { slug: string[] } // 注意:配列になります
}) {
  const path = params.slug.join('/')
  return <h1>現在のパス: {path}</h1>
}

オプショナル Catch-All:[[...slug]]

Catch-All [...slug] は、パラメータがない場合(/docs)にはマッチしません
ルートパス(/docs)も含めてマッチさせたい場合は、二重角括弧 [[...slug]] を使います。

マッチング例:

  • /docsparams.slug = undefined(または空配列)
  • /docs/aparams.slug = ['a']

これを使えば、ドキュメントのトップページと各記事ページを1つのファイルで処理できます。

第三章:静的生成 generateStaticParams

動的ルートはデフォルトではリクエスト時にレンダリング(SSR)されますが、ブログ記事のように内容が変わらないページなら、ビルド時に静的 HTML を生成(SSG)した方が高速です。

そのために使うのが generateStaticParams です(Pages Router 時代の getStaticPaths に相当)。

// app/blog/[slug]/page.tsx

// ビルド時に実行され、生成すべきパスのリストを返す
export async function generateStaticParams() {
  const posts = await fetch('https://api.example.com/posts').then(r => r.json())

  // 返り値は params オブジェクトの配列
  return posts.map((post) => ({
    slug: post.slug,
  }))
}

export default async function BlogPost({ params }) {
  // ...
}

使い分けの基準

  • 使うべき:ブログ、ニュース、製品詳細、ドキュメント(更新頻度が低い、全ユーザーに同じ内容)
  • 使わない:検索結果、ユーザープロフィール(数が膨大)、リアルタイムデータ

大規模サイトでの戦略(ISR / On-demand)

記事が10万件ある場合、ビルド時に全部生成すると時間がかかりすぎます。
その場合は、一部(最新100件など)だけ generateStaticParams で返し、残りはリクエストがあった時に生成するように設定できます。

export const dynamicParams = true // デフォルト true:未生成のパスは初回アクセス時に生成
// false にすると、generateStaticParams にないパスは 404 になる

第四章:TypeScript による型安全なパラメータ処理

params はいつでも string

Next.js の仕様上、URL パラメータは常に文字列です。しかし、アプリ内では数値 ID (/products/123) として扱いたい場合が多々あります。
そのまま parseInt(params.id) としてもいいのですが、もしユーザーが /products/abc と入力したら? NaN になってバグの原因になります。

Zod を使ったランタイムバリデーション

私は Zod を使って、入り口(Page コンポーネント)でパラメータを厳格にチェックすることを推奨しています。

npm install zod
// app/products/[id]/page.tsx
import { notFound } from 'next/navigation'
import { z } from 'zod'

// パラメータのスキーマ定義
const paramsSchema = z.object({
  id: z.coerce.number().int().positive(), // 文字列を数値に変換し、正の整数かチェック
})

export default async function ProductPage({
  params
}: {
  params: { id: string }
}) {
  // 安全にパース
  const parsed = paramsSchema.safeParse(params)

  if (!parsed.success) {
    // バリデーション失敗(例: /products/abc)なら 404
    notFound()
  }

  const { id } = parsed.data // ここで id は number 型になる!

  // これ以降は安心して数値として扱える
  const product = await fetchProduct(id)
  // ...
}

このパターンのメリット:

  1. 型安全id が確実に number であることが保証される。
  2. バグ防止:不正な URL パラメータによるクラッシュや予期せぬ DB エラーを防げる。
  3. 明確なエラー処理:不正なリクエストは早期に 404 として弾ける。

実践テクニック:URL エンコード問題

日本語 URL (/blog/こんにちは) を使う場合、params.slug にはエンコードされた文字列(%E3%81...)が入ってくることがあります(環境によります)。
安全のため、使用前にデコードしておくと良いでしょう。

const slug = decodeURIComponent(params.slug)

まとめ

Next.js の動的ルーティングは、[slug] の基本さえ押さえれば非常に直感的です。
しかし、本番レベルのアプリケーションでは、generateStaticParams によるパフォーマンス最適化や、Zod による入力値検証が欠かせません。

  1. 基本は [slug]、階層が深いなら [...slug]
  2. 静的コンテンツなら generateStaticParams で爆速化。
  3. パラメータは Zod でバリデーション して安全に使う。

これらを意識するだけで、あなたの Next.js アプリはより堅牢で、高速なものになるはずです。

Next.js 動的ルーティング実装フロー

パラメータ定義から静的生成、バリデーションまでのステップ

⏱️ Estimated time: 20 min

  1. 1

    Step1: 動的セグメントの作成

    `app/blog/[slug]/page.tsx` のように、フォルダ名を角括弧で囲んで作成します。これで `/blog/anything` へのアクセスをキャッチできます。
  2. 2

    Step2: パラメータの受け取り

    Page コンポーネントの `props` から `params` オブジェクトを受け取ります。TypeScript 型定義は `{ params: { slug: string } }` となります。
  3. 3

    Step3: 静的パラメータの生成(SSG)

    ビルド時にページを生成したい場合、`generateStaticParams` 関数をエクスポートし、可能なパラメータの配列(例: `[{ slug: 'a' }, { slug: 'b' }]`)を返します。
  4. 4

    Step4: パラメータの検証

    受け取った `params` を Zod などのライブラリで検証・変換します。数値が必要な場合やフォーマットが決まっている場合に、不正なアクセスを早期に遮断できます。

FAQ

getStaticPaths と generateStaticParams の違いは?
機能はほぼ同じですが、`generateStaticParams` は App Router 専用で、構文が簡略化されています。また、`getStaticProps` のようなデータ取得関数と分ける必要がなく、コンポーネントと同じファイルに記述できます。
動的ルートでクエリパラメータ (?page=1) はどう取得しますか?
Page コンポーネントの `searchParams` prop から取得できます。`params` がパスパラメータ(URLの一部)なのに対し、`searchParams` はクエリ文字列用です。`export default function Page({ searchParams }) { ... }` のように使います。
動的ルートのページタイトルを動的に変えるには?
Page コンポーネントと同じファイルで `generateMetadata` 関数をエクスポートします。この関数も `params` を受け取れるので、DB からデータを取得して `title` や `description` を動的に生成できます。
catch-all ルートのパラメータ配列の順序はどうなりますか?
パスの階層順になります。`/a/b/c` という URL なら、`['a', 'b', 'c']` という配列になります。0番目が親階層、末尾が最深階層です。

3 min read · 公開日: 2025年12月25日 · 更新日: 2026年1月22日

コメント

GitHubアカウントでログインしてコメントできます

関連記事