Next.js 15 Dynamic Segments で複雑なルーティングを実装する実践ガイド
Next.js 15のDynamic Segmentsを使った動的ルーティング、Catch-all Segments、Optional Catch-allの実装方法を具体例とともに解説します。
Next.js 15のApp Routerでは、Dynamic Segments(動的セグメント)を使って柔軟なルーティング設計が可能です。本記事では、基本的な動的ルーティングから、Catch-all Segments、Optional Catch-all、Parallel RoutesとIntercepting Routesの組み合わせまで、実務で使える実装パターンを解説します。
Dynamic Segmentsの基本と実装パターン
Dynamic Segmentsは、URLのパスを動的に処理するための機能です。フォルダ名を [slug] のように角括弧で囲むことで、そのセグメントが動的に変化するルートを定義できます。
// app/blog/[slug]/page.tsx
export default async function BlogPost({
params,
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params
return <h1>ブログ記事: {slug}</h1>
}
Next.js 15では、paramsが非同期(Promise型)になりました。これは、App Routerの非同期レンダリングに対応するための変更です。必ずawaitでアンパックしてから使用する必要があります。
複数の動的セグメントの組み合わせ
複数の動的セグメントを組み合わせることで、階層的なルーティングが実現できます。
// app/shop/[category]/[productId]/page.tsx
export default async function ProductPage({
params,
}: {
params: Promise<{ category: string; productId: string }>
}) {
const { category, productId } = await params
return (
<div>
<h1>カテゴリ: {category}</h1>
<p>商品ID: {productId}</p>
</div>
)
}
このルートは /shop/electronics/abc123 のようなURLにマッチします。
Catch-all Segmentsで任意の深さのパスを処理する
Catch-all Segmentsは、[...slug] のように三点リーダーを使うことで、任意の深さのパスセグメントを配列として受け取れます。
// app/docs/[...slug]/page.tsx
export default async function DocsPage({
params,
}: {
params: Promise<{ slug: string[] }>
}) {
const { slug } = await params
return (
<div>
<h1>ドキュメント階層</h1>
<p>パス: {slug.join(' > ')}</p>
</div>
)
}
このルートは以下のようなURLにマッチします:
/docs/getting-started→slug = ['getting-started']/docs/api/authentication/oauth→slug = ['api', 'authentication', 'oauth']
ドキュメントサイトでの実装例
以下は、Catch-all Segmentsを使ったドキュメントサイトの実装例です。
// app/docs/[...slug]/page.tsx
import { notFound } from 'next/navigation'
import { getDocContent } from '@/lib/docs'
export default async function DocsPage({
params,
}: {
params: Promise<{ slug: string[] }>
}) {
const { slug } = await params
const path = slug.join('/')
const doc = await getDocContent(path)
if (!doc) {
notFound()
}
return (
<article>
<h1>{doc.title}</h1>
<div dangerouslySetInnerHTML={{ __html: doc.content }} />
</article>
)
}
export async function generateStaticParams() {
// ビルド時に静的生成するパスのリスト
return [
{ slug: ['introduction'] },
{ slug: ['getting-started', 'installation'] },
{ slug: ['api', 'reference', 'components'] },
]
}
flowchart TD
A[ユーザーがURLにアクセス] --> B{Catch-all Segmentsでパス取得}
B --> C[slug配列をパス文字列に変換]
C --> D[getDocContentでコンテンツ取得]
D --> E{コンテンツ存在?}
E -->|Yes| F[記事をレンダリング]
E -->|No| G[404ページ表示]
F --> H[静的生成されたページを返す]
ドキュメントサイトにおけるCatch-all Segmentsの処理フロー
Optional Catch-all Segmentsでルートパスにも対応
Optional Catch-all Segmentsは、[[...slug]] のように二重角括弧で囲むことで、ルートパス(/docs)にもマッチするようになります。
// app/docs/[[...slug]]/page.tsx
export default async function DocsPage({
params,
}: {
params: Promise<{ slug?: string[] }>
}) {
const resolvedParams = await params
const slug = resolvedParams.slug || []
if (slug.length === 0) {
// /docs へのアクセス
return <h1>ドキュメントトップ</h1>
}
return <h1>パス: {slug.join(' > ')}</h1>
}
このルートは以下のようにマッチします:
/docs→slug = undefined(または[])/docs/guide→slug = ['guide']/docs/api/v2/auth→slug = ['api', 'v2', 'auth']
ブログサイトでの実装例
Optional Catch-all Segmentsは、カテゴリ・タグ・アーカイブなど複数の階層を持つブログサイトで特に有用です。
// app/blog/[[...segments]]/page.tsx
import { getBlogPosts } from '@/lib/blog'
export default async function BlogPage({
params,
searchParams,
}: {
params: Promise<{ segments?: string[] }>
searchParams: Promise<{ page?: string }>
}) {
const { segments = [] } = await params
const { page = '1' } = await searchParams
// /blog → トップページ
if (segments.length === 0) {
const posts = await getBlogPosts({ page: parseInt(page) })
return <BlogList posts={posts} />
}
// /blog/category/tech → カテゴリページ
if (segments[0] === 'category' && segments[1]) {
const posts = await getBlogPosts({
category: segments[1],
page: parseInt(page)
})
return <BlogList posts={posts} category={segments[1]} />
}
// /blog/2026/04 → アーカイブページ
if (segments.length === 2 && /^\d{4}$/.test(segments[0])) {
const posts = await getBlogPosts({
year: segments[0],
month: segments[1],
page: parseInt(page)
})
return <BlogList posts={posts} archive={`${segments[0]}/${segments[1]}`} />
}
notFound()
}
generateStaticParamsで動的ルートを静的生成する
Dynamic Segmentsを使ったページを静的生成するには、generateStaticParams関数を使います。
// app/products/[id]/page.tsx
import { getProduct, getAllProductIds } from '@/lib/products'
export async function generateStaticParams() {
const productIds = await getAllProductIds()
return productIds.map((id) => ({
id: id.toString(),
}))
}
export default async function ProductPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
const product = await getProduct(id)
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
</div>
)
}
Catch-all Segmentsの場合は、配列として返します。
// app/docs/[...slug]/page.tsx
export async function generateStaticParams() {
return [
{ slug: ['introduction'] },
{ slug: ['guide', 'getting-started'] },
{ slug: ['api', 'reference', 'hooks'] },
{ slug: ['api', 'reference', 'components', 'button'] },
]
}
動的生成と静的生成のハイブリッド運用
Next.js 15では、dynamicParamsオプションで静的生成されていないパスへのアクセス時の動作を制御できます。
// app/blog/[slug]/page.tsx
export const dynamicParams = true // デフォルト: true
export async function generateStaticParams() {
// ビルド時に生成する主要記事のみ
const popularPosts = await getPopularPosts(10)
return popularPosts.map((post) => ({
slug: post.slug,
}))
}
export default async function BlogPost({
params,
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params
const post = await getPost(slug)
if (!post) {
notFound()
}
return <article>{post.content}</article>
}
dynamicParams = true(デフォルト)の場合、generateStaticParamsで定義されていないパスにアクセスされると、その場でページを生成します(ISR: Incremental Static Regeneration)。
dynamicParams = falseにすると、定義されていないパスは404を返します。
flowchart LR
A[ユーザーリクエスト] --> B{generateStaticParamsに含まれる?}
B -->|Yes| C[ビルド時に生成済みのページを返す]
B -->|No| D{dynamicParams設定}
D -->|true| E[オンデマンドでページ生成]
D -->|false| F[404ページを返す]
E --> G[生成されたページをキャッシュ]
Dynamic Segmentsにおける静的生成とオンデマンド生成のフロー
Parallel RoutesとIntercepting Routesの組み合わせ
Dynamic Segmentsは、Parallel Routes(@folder)やIntercepting Routes((.)folder)と組み合わせることで、より複雑なUIパターンを実装できます。
以下は、画像ギャラリーでモーダル表示を実装する例です。
app/
├── gallery/
│ ├── page.tsx # ギャラリー一覧
│ ├── @modal/
│ │ └── (.)photo/
│ │ └── [id]/
│ │ └── page.tsx # モーダル表示用
│ └── photo/
│ └── [id]/
│ └── page.tsx # 直接アクセス用
└── layout.tsx
// app/gallery/layout.tsx
export default function GalleryLayout({
children,
modal,
}: {
children: React.ReactNode
modal: React.ReactNode
}) {
return (
<>
{children}
{modal}
</>
)
}
// app/gallery/@modal/(.)photo/[id]/page.tsx
import { getPhoto } from '@/lib/photos'
import { Modal } from '@/components/Modal'
export default async function PhotoModal({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
const photo = await getPhoto(id)
return (
<Modal>
<img src={photo.url} alt={photo.title} />
</Modal>
)
}
// app/gallery/photo/[id]/page.tsx
import { getPhoto } from '@/lib/photos'
export default async function PhotoPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
const photo = await getPhoto(id)
return (
<div>
<h1>{photo.title}</h1>
<img src={photo.url} alt={photo.title} />
</div>
)
}
この構成により:
- ギャラリー一覧から画像をクリック → モーダルで開く(
@modal/(.)photo/[id]) - 画像のURLに直接アクセス → 専用ページで開く(
photo/[id])
という動作を実現できます。
まとめ
Next.js 15のDynamic Segmentsを使った実装パターンをまとめます。
- 基本的なDynamic Segments:
[slug]で単一の動的パラメータを受け取る。Next.js 15ではparamsが非同期になったため、必ずawaitでアンパックする - Catch-all Segments:
[...slug]で任意の深さのパスを配列として受け取る。ドキュメントサイトやファイルブラウザに最適 - Optional Catch-all Segments:
[[...slug]]でルートパスにもマッチする。ブログのカテゴリ・アーカイブなど階層的なコンテンツに便利 - generateStaticParams: ビルド時に静的生成するパスを定義。
dynamicParamsで未定義パスの動作を制御可能 - Parallel Routes + Intercepting Routes: モーダル表示など、同じコンテンツを複数の表示形式で提供する場合に有効
Dynamic Segmentsは、柔軟なルーティングと静的生成を両立できる強力な機能です。プロジェクトの要件に応じて、適切なパターンを選択してください。