メインコンテンツへスキップ
#Web制作 約11分で読めます

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 RoutesOptional Catch-all RoutesParallel RoutesIntercepting 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では、paramssearchParamsの型定義を明示的に行うことで、型安全性を確保できます。

// 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型定義でparamssearchParamsを明示的に型付けする
  • zodなどでパラメータをサーバー側で検証する
  • SEO対策としてgenerateMetadataでメタタグを動的生成する
  • Intercepting Routesは直接アクセス時の通常ページも実装する
  • 静的生成可能なページはgenerateStaticParamsで事前レンダリングする

これらのパターンを適切に組み合わせることで、保守性とパフォーマンスを両立した高度なルーティングシステムを構築できます。

参考リンク

#Next.js #ルーティング #App Router #TypeScript #React
シェア