Next.js 15 Dynamic Segments 実装パターン集|Catch-all・Optional・Parallel Routesの使い分け【2026年6月最新】
Next.js 15 Dynamic Segmentsの実践的な実装パターンを網羅。Catch-all Routes、Optional Catch-all、Parallel Routesなど、複雑なルーティング要件に対応する設計手法を解説します。
Next.js 15のApp Routerでは、Dynamic Segmentsを使った柔軟なルーティング設計が可能です。しかし、Catch-all Routes、Optional Catch-all Routes、Parallel Routes、Intercepting Routesなど、複数のパターンが存在し、どの場面でどの実装方法を選ぶべきか迷うケースが多いです。
この記事では、Next.js 15の公式ドキュメント(2026年6月時点)に基づき、Dynamic Segmentsの実装パターンを体系的に整理します。各パターンの使い分け基準、TypeScript型定義、SEO最適化手法、実際のプロジェクトでの応用例を含めた実践ガイドです。
Dynamic Segments の基本と型安全性の確保
Next.js 15のDynamic Segmentsは、ファイルシステムベースのルーティングで動的なパスを扱うための機構です。app/blog/[slug]/page.tsx のように[segment]記法でフォルダを作成すると、paramsオブジェクト経由で値を受け取れます。
基本的な Dynamic Segments の実装
// app/blog/[slug]/page.tsx
import { Metadata } from 'next'
type Props = {
params: { slug: string }
searchParams: { [key: string]: string | string[] | undefined }
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const slug = params.slug
return {
title: `${slug} | ブログ`,
description: `${slug}に関する詳細記事です。`,
}
}
export default function BlogPost({ params }: Props) {
return (
<article>
<h1>{params.slug}</h1>
<p>記事の内容がここに入ります。</p>
</article>
)
}
TypeScript型定義のベストプラクティス
Next.js 15では、paramsとsearchParamsの型定義を明示的に行うことで、型安全性を確保できます。
// types/page.ts
export type PageProps<
TParams = Record<string, string>,
TSearchParams = Record<string, string | string[] | undefined>
> = {
params: TParams
searchParams: TSearchParams
}
// app/products/[category]/[id]/page.tsx
import { PageProps } from '@/types/page'
type ProductPageParams = {
category: string
id: string
}
export default function ProductPage({ params }: PageProps<ProductPageParams>) {
const { category, id } = params
return (
<div>
<h1>カテゴリ: {category}</h1>
<p>商品ID: {id}</p>
</div>
)
}
パラメータの検証とエラーハンドリング
Dynamic Segmentsで受け取った値は必ず検証すべきです。zodなどのバリデーションライブラリを活用することで、不正な値をサーバー側で弾けます。
// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation'
import { z } from 'zod'
const slugSchema = z.string().regex(/^[a-z0-9-]+$/)
export default async function BlogPost({ params }: Props) {
const parseResult = slugSchema.safeParse(params.slug)
if (!parseResult.success) {
notFound()
}
const slug = parseResult.data
// slugを使った処理
const post = await fetchPost(slug)
if (!post) {
notFound()
}
return <article>{/* 記事コンテンツ */}</article>
}
Catch-all Routes vs Optional Catch-all Routes の使い分け
Next.js 15では、Catch-all Routes([...slug])とOptional Catch-all Routes([[...slug]])の2種類が提供されています。両者の違いはルートパスがマッチするかどうかです。
Catch-all Routes([...slug])の実装
Catch-all Routesは、1つ以上のセグメントが必須です。
// app/docs/[...slug]/page.tsx
type DocsPageProps = {
params: { slug: string[] }
}
export default function DocsPage({ params }: DocsPageProps) {
const path = params.slug.join('/')
return (
<div>
<h1>ドキュメント: {path}</h1>
</div>
)
}
// マッチ例:
// /docs/getting-started → params.slug = ["getting-started"]
// /docs/api/reference → params.slug = ["api", "reference"]
// マッチしない:
// /docs → 404エラー
Optional Catch-all Routes([[...slug]])の実装
Optional Catch-all Routesは、0個以上のセグメントにマッチします。ルートパスも含めて処理したい場合に使用します。
// app/shop/[[...categories]]/page.tsx
type ShopPageProps = {
params: { categories?: string[] }
}
export default function ShopPage({ params }: ShopPageProps) {
const categories = params.categories || []
return (
<div>
<h1>
{categories.length === 0
? '全商品'
: `カテゴリ: ${categories.join(' > ')}`}
</h1>
</div>
)
}
// マッチ例:
// /shop → params.categories = undefined
// /shop/electronics → params.categories = ["electronics"]
// /shop/electronics/computers → params.categories = ["electronics", "computers"]
実務での使い分け基準
| パターン | 使用ケース | ルートパスの扱い |
|---|---|---|
[...slug] | ドキュメントサイト、ブログアーカイブ(セグメント必須) | ルートは404 |
[[...slug]] | Eコマースカテゴリ、検索結果、タグフィルタ(ルート含む) | ルートもマッチ |
graph TD
A[ユーザーリクエスト] --> B{セグメント数}
B -->|0個| C[Optional Catch-all<br/>[[...slug]]]
B -->|1個以上| D{ルートパスも処理?}
D -->|Yes| C
D -->|No| E[Catch-all<br/>[...slug]]
C --> F[全商品表示や<br/>フィルタなしページ]
E --> G[特定セグメント必須の<br/>ドキュメントページ]
Optional Catch-allは柔軟性が高い一方、ルートパスの処理を明示的に実装する必要があります。
Parallel Routes によるレイアウト分割実装
Next.js 15のParallel Routesは、同一レイアウト内で複数のページを同時にレンダリングできる機能です。@記号でフォルダを作成し、layout.tsxで受け取ります。
Parallel Routesの基本構造
// app/dashboard/layout.tsx
export default function DashboardLayout({
children,
analytics,
team,
}: {
children: React.ReactNode
analytics: React.ReactNode
team: React.ReactNode
}) {
return (
<div className="grid grid-cols-2 gap-4">
<div className="col-span-2">{children}</div>
<div>{analytics}</div>
<div>{team}</div>
</div>
)
}
app/
└── dashboard/
├── layout.tsx
├── page.tsx # メインコンテンツ(children)
├── @analytics/
│ └── page.tsx # analytics スロット
└── @team/
└── page.tsx # team スロット
Dynamic Segments と Parallel Routes の組み合わせ
Parallel Routes内でDynamic Segmentsを使うことで、URLパラメータに応じて複数スロットの内容を動的に変えることができます。
// app/dashboard/[projectId]/layout.tsx
type DashboardLayoutProps = {
params: { projectId: string }
children: React.ReactNode
overview: React.ReactNode
tasks: React.ReactNode
}
export default function ProjectLayout({
params,
children,
overview,
tasks,
}: DashboardLayoutProps) {
return (
<div>
<h1>プロジェクト: {params.projectId}</h1>
<div className="grid grid-cols-3">
<div className="col-span-2">{children}</div>
<aside>
<section>{overview}</section>
<section>{tasks}</section>
</aside>
</div>
</div>
)
}
// app/dashboard/[projectId]/@overview/page.tsx
export default async function OverviewSlot({ params }: { params: { projectId: string } }) {
const stats = await fetchProjectStats(params.projectId)
return (
<div>
<h2>概要</h2>
<p>タスク数: {stats.taskCount}</p>
</div>
)
}
default.tsx によるフォールバック処理
Parallel Routesでは、マッチするページがない場合にdefault.tsxがレンダリングされます。これにより、一部スロットのみ存在するページでも正常に表示できます。
// app/dashboard/@analytics/default.tsx
export default function AnalyticsDefault() {
return <div>分析データがありません</div>
}
実務での活用例
| ユースケース | 構成 |
|---|---|
| 管理画面ダッシュボード | メインコンテンツ + サイドバー統計 + アクティビティフィード |
| Eコマース商品ページ | 商品詳細 + レコメンド + レビュー |
| ブログ記事 | 本文 + 目次 + 関連記事 |
Intercepting Routes による UI 最適化パターン
Intercepting Routesは、特定のナビゲーションをインターセプトしてモーダル表示する機能です。(.)、(..)、(..)(..)、(...)記法でパスを指定します。
モーダルによる画像プレビュー実装
// app/photos/[id]/page.tsx(通常ページ)
export default function PhotoPage({ params }: { params: { id: string } }) {
return (
<div>
<h1>写真 {params.id}</h1>
<img src={`/photos/${params.id}.jpg`} alt="" />
</div>
)
}
// app/@modal/(.)photos/[id]/page.tsx(インターセプト)
'use client'
import { useRouter } from 'next/navigation'
import { Modal } from '@/components/Modal'
export default function PhotoModal({ params }: { params: { id: string } }) {
const router = useRouter()
return (
<Modal onClose={() => router.back()}>
<img src={`/photos/${params.id}.jpg`} alt="" />
</Modal>
)
}
sequenceDiagram
participant User as ユーザー
participant Grid as 写真一覧
participant Modal as モーダル
participant Page as 詳細ページ
User->>Grid: 写真クリック
Grid->>Modal: Intercepting Route発火
Modal-->>User: モーダル表示
User->>User: ブラウザバック
Modal-->>Grid: 一覧に戻る
Note over User,Page: 直接URL入力時
User->>Page: /photos/123に直接アクセス
Page-->>User: フルページ表示
Intercepting Routesを使うと、ユーザーは一覧ページからシームレスにプレビューでき、ブラウザバックで元の位置に戻れます。
Intercepting Routesの記法
| 記法 | 相対パス | 使用例 |
|---|---|---|
(.) | 同階層 | app/feed/(.)photo/[id] |
(..) | 1つ上 | app/posts/[id]/(..)comments |
(..)(..) | 2つ上 | app/a/b/(..)(..)/c |
(...) | appルートから | app/@modal/(...)auth/login |
SEO対策としての実装戦略
Intercepting Routesはクライアント側のナビゲーションでのみ動作します。直接URLアクセス時は通常ページが表示されるため、以下の対策が必要です。
// app/photos/[id]/page.tsx
import type { Metadata } from 'next'
export async function generateMetadata({ params }: { params: { id: string } }): Promise<Metadata> {
const photo = await fetchPhoto(params.id)
return {
title: photo.title,
description: photo.description,
openGraph: {
images: [photo.url],
},
}
}
export default function PhotoPage({ params }: { params: { id: string } }) {
// SEOクローラーはこちらをインデックス
return (
<article>
<h1>{photo.title}</h1>
<img src={photo.url} alt={photo.title} />
</article>
)
}
Route Groups による論理的なルーティング設計
Route Groups((folder)記法)は、URLに影響を与えずにフォルダを整理する機能です。レイアウトの切り替えや、ルート構造の論理的な分類に使用します。
認証状態によるレイアウト分岐
app/
├── (auth)/
│ ├── layout.tsx # 認証ページ専用レイアウト
│ ├── login/
│ │ └── page.tsx # /login
│ └── register/
│ └── page.tsx # /register
└── (dashboard)/
├── layout.tsx # ダッシュボード専用レイアウト
├── settings/
│ └── page.tsx # /settings
└── profile/
└── page.tsx # /profile
// app/(auth)/layout.tsx
export default function AuthLayout({ children }: { children: React.ReactNode }) {
return (
<div className="min-h-screen bg-gray-50">
<div className="max-w-md mx-auto pt-20">
{children}
</div>
</div>
)
}
// app/(dashboard)/layout.tsx
import { Sidebar } from '@/components/Sidebar'
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex">
<Sidebar />
<main className="flex-1">{children}</main>
</div>
)
}
多言語対応とRoute Groups
app/
├── (ja)/
│ ├── layout.tsx
│ └── about/
│ └── page.tsx # /about(日本語)
└── (en)/
├── layout.tsx
└── about/
└── page.tsx # /about(英語)
注意: 上記の構造では両方とも/aboutになるため、Next.jsはエラーを返します。多言語対応には後述のDynamic Segmentsと組み合わせた実装が推奨されます。
複雑なルーティング要件の実装パターン集
パターン1: 多言語 + カテゴリ階層
app/
└── [locale]/
└── blog/
└── [...categories]/
└── page.tsx
// app/[locale]/blog/[...categories]/page.tsx
type BlogPageProps = {
params: {
locale: string
categories: string[]
}
}
export async function generateMetadata({ params }: BlogPageProps): Promise<Metadata> {
const { locale, categories } = params
const category = categories[categories.length - 1]
return {
title: `${category} | Blog`,
alternates: {
canonical: `/${locale}/blog/${categories.join('/')}`,
languages: {
'ja-JP': `/ja/blog/${categories.join('/')}`,
'en-US': `/en/blog/${categories.join('/')}`,
},
},
}
}
export default function BlogCategory({ params }: BlogPageProps) {
const breadcrumbs = params.categories.map((cat, i) => ({
label: cat,
href: `/${params.locale}/blog/${params.categories.slice(0, i + 1).join('/')}`,
}))
return (
<div>
<nav>
{breadcrumbs.map(b => (
<a key={b.href} href={b.href}>{b.label}</a>
))}
</nav>
</div>
)
}
// マッチ例:
// /ja/blog/tech → { locale: "ja", categories: ["tech"] }
// /en/blog/tech/nextjs → { locale: "en", categories: ["tech", "nextjs"] }
パターン2: Eコマースの商品検索フィルタ
app/
└── shop/
└── [[...filters]]/
└── page.tsx
// app/shop/[[...filters]]/page.tsx
type ShopPageProps = {
params: { filters?: string[] }
searchParams: {
sort?: string
page?: string
}
}
export default async function ShopPage({ params, searchParams }: ShopPageProps) {
const filters = params.filters || []
const [category, subcategory, ...tags] = filters
const products = await fetchProducts({
category,
subcategory,
tags,
sort: searchParams.sort,
page: Number(searchParams.page) || 1,
})
return (
<div>
<h1>
{category ? `${category} > ${subcategory || ''}` : '全商品'}
</h1>
<ProductList products={products} />
</div>
)
}
// URL例:
// /shop → 全商品
// /shop/electronics → カテゴリ
// /shop/electronics/laptops → サブカテゴリ
// /shop/electronics/laptops/gaming → タグ付きフィルタ
パターン3: ユーザーダッシュボード + タブ切り替え
app/
└── users/
└── [username]/
├── layout.tsx
├── @posts/
│ └── page.tsx
├── @likes/
│ └── page.tsx
└── @followers/
└── page.tsx
// app/users/[username]/layout.tsx
type UserLayoutProps = {
params: { username: string }
children: React.ReactNode
posts: React.ReactNode
likes: React.ReactNode
followers: React.ReactNode
}
export default function UserLayout({
params,
children,
posts,
likes,
followers,
}: UserLayoutProps) {
return (
<div>
<header>
<h1>@{params.username}</h1>
</header>
<nav>
<a href={`/users/${params.username}`}>投稿</a>
<a href={`/users/${params.username}/likes`}>いいね</a>
<a href={`/users/${params.username}/followers`}>フォロワー</a>
</nav>
<div>
{children}
{posts}
{likes}
{followers}
</div>
</div>
)
}
generateStaticParams による静的生成最適化
Next.js 15では、Dynamic Segmentsを使ったページをビルド時に静的生成できます。generateStaticParams関数でパスのリストを返すことで、事前レンダリングが可能です。
基本的な実装
// app/blog/[slug]/page.tsx
export async function generateStaticParams() {
const posts = await fetchAllPosts()
return posts.map(post => ({
slug: post.slug,
}))
}
export default function BlogPost({ params }: { params: { slug: string } }) {
return <article>{/* コンテンツ */}</article>
}
ネストしたDynamic Segmentsの静的生成
// app/blog/[category]/[slug]/page.tsx
export async function generateStaticParams() {
const categories = await fetchCategories()
const paths = await Promise.all(
categories.map(async (category) => {
const posts = await fetchPostsByCategory(category.slug)
return posts.map(post => ({
category: category.slug,
slug: post.slug,
}))
})
)
return paths.flat()
}
Catch-all Routesの静的生成
// app/docs/[...slug]/page.tsx
export async function generateStaticParams() {
const docs = await fetchAllDocs()
return docs.map(doc => ({
slug: doc.path.split('/'),
}))
}
// 生成例:
// { slug: ["getting-started"] }
// { slug: ["api", "reference", "components"] }
まとめ
Next.js 15のDynamic Segmentsは、以下のパターンを組み合わせることで、複雑なルーティング要件に対応できます。
- 基本的なDynamic Segments:
[slug]で単一パラメータを受け取る - Catch-all Routes:
[...slug]で1つ以上のセグメントに対応(ルートは404) - Optional Catch-all Routes:
[[...slug]]でルートを含む全セグメントに対応 - Parallel Routes:
@folderで同一レイアウト内の複数スロットを同時レンダリング - Intercepting Routes:
(.)記法でモーダル表示などのUI最適化を実現 - Route Groups:
(folder)でURLに影響を与えずレイアウトを分岐 - generateStaticParams: Dynamic Segmentsページの静的生成でパフォーマンス向上
実装時のポイント:
- TypeScript型定義で
paramsとsearchParamsを明示的に型付けする - zodなどでパラメータをサーバー側で検証する
- SEO対策として
generateMetadataでメタタグを動的生成する - Intercepting Routesは直接アクセス時の通常ページも実装する
- 静的生成可能なページは
generateStaticParamsで事前レンダリングする
これらのパターンを適切に組み合わせることで、保守性とパフォーマンスを両立した高度なルーティングシステムを構築できます。