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

Next.js App Router 実践:ルートグループとネストされたレイアウトで大規模プロジェクトのディレクトリ混沌を解決する

午前1時、私は VS Code の左側にあるファイルツリーを呆然と見つめていました。app ディレクトリの下には120個以上のフォルダがひしめき合っており、バックエンドのユーザー管理ページを探そうとして、dashboard-user-listadmin-usersbackend-user-management の3つのフォルダを行ったり来たりして5分が過ぎました――どれも似たような名前だったからです。

さらに悲惨だったのは朝のコードレビューです。小林さんが新しく /about ルートを追加したのですが、マーケティングチームが先週コミットした /about と衝突してしまいました。二人は顔を見合わせて言いました。「私の about は会社についてで、僕の about は製品についてだ。どうして僕が変えなきゃいけないの?」

正直なところ、これは初めてのことではありません。プロジェクト開始当初は十数ページしかなく、フラットなディレクトリ構造はとてもすっきりしていました。半年が過ぎ、機能が10倍に増えると、app ディレクトリ全体が誰も整理していないクローゼットのようになりました――中にあるのは分かっているけど、探すたびにひっくり返さなければなりません。

もしあなたが Next.js プロジェクトを行っており、チームが3人以上で、ページ数が50を超えているなら、十中八九同じ問題に遭遇するでしょう。良いニュースは、Next.js App Router がこの問題を解決するために特別に4つの機能を提供していることです:ルートグループ、ネストされたレイアウト、並行ルート、インターセプト・ルート。しかし、ネット上のチュートリアルの多くはデモレベルの「Hello World」であり、実際にプロジェクトにどう適用するかは依然として謎のままです。

この記事では、実際の EC プロジェクトを例に、これら4つの機能を具体的にどう使うか、そして混沌としたディレクトリをメンテナンス可能で拡張性の高い構造に再編成する方法を解説します。

痛みの再現 - 従来のディレクトリ構造の3つの問題

フラットなディレクトリのジレンマ

まず、以前のディレクトリがどうなっていたか見てみましょう:

app/
├── page.tsx              # トップページ
├── about/page.tsx        # 会社概要
├── products/page.tsx     # 商品一覧
├── product-detail/[id]/page.tsx
├── cart/page.tsx
├── checkout/page.tsx
├── dashboard/page.tsx    # 管理画面トップ
├── dashboard-users/page.tsx
├── dashboard-users-active/page.tsx
├── dashboard-users-blocked/page.tsx
├── dashboard-orders/page.tsx
├── dashboard-orders-pending/page.tsx
├── dashboard-settings/page.tsx
├── auth-login/page.tsx   # ログイン
├── auth-register/page.tsx
└── ... (他 80+ 個のフォルダ)

見るだけで頭が痛くなります。さらに深刻なのは、URL パスも奇妙になることです:/dashboard/users/active ではなく /dashboard-users-active です。競合を避けるために、フォルダ名に様々なプレフィックスを付けざるを得ませんでしたが、これは問題を隠しているに過ぎません。

どのページがフロントエンドで、どれがバックエンドで、どれが認証関連なのか、一目で区別することは全くできません。新しいメンバーがチームに加わると、ディレクトリ構造に慣れるだけで数日かかります。

レイアウトの重複とメンテナンスの困難さ

フロントエンドとバックエンドのレイアウトは全く異なります。フロントエンドにはトップナビゲーションとフッターがあり、バックエンドにはサイドバーと権限管理があります。従来の方法では、各ページコンポーネントで手動でレイアウトをインポートしていました:

// app/dashboard-users/page.tsx
import DashboardLayout from '@/components/DashboardLayout'

export default function UsersPage() {
  return (
    <DashboardLayout>
      <div>ユーザー管理コンテンツ</div>
    </DashboardLayout>
  )
}

この書き方にはいくつか問題があります。第一に、漏れやすいこと――新しいバックエンドページを作成するときにレイアウトを追加し忘れて、ページが真っ白になることがあります。第二に、統一されていないこと――ある人は DashboardLayout を使い、ある人は AdminLayout を使い、最終的にメンテナンスが混沌とします。

さらに深刻なのは、バックエンドのレイアウトを変更する場合(例えばサイドバーのスタイル変更など)、20個のファイルを確認して、各ページが正しいレイアウトコンポーネントを使用しているか確かめる必要があることです。正直、レイアウトを変更するたびに冷や汗をかきます。

モーダルとポップアップのルートジレンマ

プロダクトマネージャーから要望がありました:ユーザーが商品一覧ページで商品をクリックすると、詳細を表示するモーダルがポップアップし、同時に URL が /product/123 に変わり、ユーザーがこのリンクを共有できるようにしたいとのこと。理にかなっていますが、実装するのは頭痛の種です。

従来の方法では、クライアントの状態管理を使用し、モーダルの表示/非表示を手動で制御し、URL を手動で操作していました。コードは非常に汚くなり、致命的な問題があります:ユーザーがページを更新するとモーダルが消えてしまい、体験が損なわれます。

「じゃあ2セット作ればいいじゃないか」と言うかもしれません――1つはモーダルバージョン、もう1つは完全なページバージョンです。確かに可能ですが、同じコンテンツに対して2つのコードをメンテナンスすることを意味します。製品ロジックが変われば両方を修正しなければならず、バグが出やすくなります。

Instagram のような体験――一覧ページで画像をクリックするとモーダルがポップアップし、ページを更新すると完全な大きな画像が表示される――は簡単そうに見えますが、従来のルーティング方法で実装するのは本当に面倒です。

ルートグループ(Route Groups)- URL に影響を与えずに論理的にグループ化

ルートグループとは

簡単に言えば、フォルダ名を括弧で囲むことです。例えば (marketing) のように。この括弧内の名前は URL に現れません。抽象的に聞こえるかもしれませんが、コードを直接見てみましょう:

app/
├── (marketing)/           # フロントエンドマーケティングページグループ
│   ├── layout.tsx         # フロントエンド専用レイアウト
│   ├── page.tsx           # URL: /
│   ├── about/page.tsx     # URL: /about
│   └── products/page.tsx  # URL: /products
├── (shop)/                # EC 機能グループ
│   ├── layout.tsx
│   ├── cart/page.tsx      # URL: /cart
│   └── checkout/page.tsx  # URL: /checkout
└── (dashboard)/           # バックエンド管理グループ
    ├── layout.tsx
    ├── dashboard/page.tsx # URL: /dashboard
    ├── users/page.tsx     # URL: /users (/dashboard/users ではありません!)
    └── orders/page.tsx    # URL: /orders

注目してください。(marketing)(shop)(dashboard) という括弧内の名前は URL に一切現れません。(marketing)/about/page.tsx のルートパスは依然として /about であり、/marketing/about ではありません。

「これって余計なお世話じゃない? 括弧内の名前が URL に影響しないなら、何のためにあるの?」と思うかもしれません。

実は、ルートグループの核心的な価値は ルート ではなく コードの整理 にあります。ビジネスロジック、チームの役割分担、機能モジュールごとにルートをグループ化でき、ディレクトリ構造が一目瞭然になりますが、URL が冗長になることはありません。

実践例:チームごとのグループ化

私たちのチームには3つのグループがあります:マーケティンググループは公式サイトとプロモーションページを担当し、プロダクトグループは EC 機能を担当し、バックエンドグループは管理システムを担当します。以前は全員が同じ app ディレクトリで作業していたため、ファイル競合は日常茶飯事でした。ルートグループを使用してからは:

app/
├── (team-marketing)/      # マーケティングチーム担当
│   ├── layout.tsx
│   ├── page.tsx           # トップページ
│   ├── about/page.tsx
│   └── pricing/page.tsx
├── (team-product)/        # プロダクトチーム担当
│   ├── layout.tsx
│   ├── products/page.tsx
│   └── product/[id]/page.tsx
└── (team-backend)/        # バックエンドチーム担当
    ├── layout.tsx
    ├── dashboard/page.tsx
    └── admin/page.tsx

このメリットは明らかです:

  1. ファイル競合の減少。マーケティンググループは (team-marketing) で、プロダクトグループは (team-product) で作業し、お互い干渉しません。Git のマージ時の競合が大幅に減りました。

  2. コードレビューがより明確に。Pull Request を見る際、この変更がどのチームのコードに影響するかが一目で分かります。

  3. 独立したレイアウト。各ルートグループは独自の layout.tsx を持つことができ、マーケティングページはマーケティングスタイルのレイアウトを、バックエンドページはバックエンドスタイルのレイアウトを使用し、手動で各ページにインポートする必要がありません。

別の例:レイアウトタイプごとのグループ化

チームごとではなく、レイアウトタイプごとに分けるプロジェクトもあります:

app/
├── (with-nav)/            # トップナビゲーションありのページ
│   ├── layout.tsx
│   ├── page.tsx
│   ├── about/page.tsx
│   └── products/page.tsx
├── (fullscreen)/          # 全画面ページ(ナビゲーションなし)
│   ├── layout.tsx
│   └── video/[id]/page.tsx
└── (auth)/                # 認証ページ(シンプルレイアウト)
    ├── layout.tsx
    ├── login/page.tsx
    └── register/page.tsx

ログイン・登録ページは通常、トップナビゲーションやフッター情報を必要としないため、(auth) ルートグループで個別に管理し、シンプルなレイアウトを与えます。動画再生ページは全画面表示が必要なため、こちらも個別にグループ化します。

注意事項

ルートグループは便利ですが、落とし穴があります:異なるルートグループ内に同じルートパスを持つことはできません。

例えば、(marketing)/about/page.tsx(shop)/about/page.tsx を同時に持つことはできません。どちらも /about に解決されるため、Next.js はどちらを使えばいいか分からず、エラーになります。

解決策は、ルートをしっかり計画し、各パスが一意であることを確認することです。どうしても避けられない場合は、どちらかにプレフィックスを追加します(例:(shop)/about-us/page.tsx)。

もう一点、ルートグループの命名には意味を持たせてください。(group1)(group2) のような無意味な名前ではなく、(marketing)(dashboard)(auth) のような一目で分かる名前を使い、チームの連携をスムーズにしましょう。

ネストされたレイアウト(Nested Layouts)- 自動レイアウト継承

ネストされたレイアウトの仕組み

ルートグループはディレクトリ整理の問題を解決しましたが、レイアウトの階層関係という問題が残っています。例えばバックエンド管理システムは、通常このような構造を持っています:

  • 第1層:トップタイトルバー + サイドバー(すべてのバックエンドページで共有)
  • 第2層:ユーザー管理モジュールには独自のタブ(アクティブユーザー、凍結ユーザー)がある
  • 第3層:具体的なページコンテンツ

Next.js のネストされたレイアウトは、このようなシナリオのために設計されています。異なる階層のフォルダに layout.tsx を置くと、それらは自動的にネストされます:

app/(dashboard)/
├── layout.tsx              # 第1層レイアウト: トップナビゲーション + サイドバー
├── users/
│   ├── layout.tsx          # 第2層レイアウト: ユーザー管理タブ
│   ├── active/page.tsx     # /users/active
│   └── blocked/page.tsx    # /users/blocked
└── orders/
    ├── layout.tsx          # 第2層レイアウト: 注文管理タブ
    ├── pending/page.tsx
    └── completed/page.tsx

ユーザーが /users/active にアクセスすると、レンダリング階層は次のようになります:

DashboardLayout (第1層)
  └─ UsersLayout (第2層)
      └─ ActiveUsersPage (ページ)

コードはこんな感じです:

// app/(dashboard)/layout.tsx - 第1層レイアウト
export default function DashboardLayout({
  children
}: {
  children: React.ReactNode
}) {
  return (
    <div className="dashboard-container">
      <TopBar />
      <div className="content-area">
        <Sidebar />
        <main>{children}</main>
      </div>
    </div>
  )
}

// app/(dashboard)/users/layout.tsx - 第2層レイアウト
export default function UsersLayout({
  children
}: {
  children: React.ReactNode
}) {
  return (
    <div className="users-section">
      <div className="tabs">
        <Link href="/users/active">アクティブユーザー</Link>
        <Link href="/users/blocked">凍結ユーザー</Link>
      </div>
      {children}
    </div>
  )
}

// app/(dashboard)/users/active/page.tsx - ページ
export default function ActiveUsersPage() {
  return <div>アクティブユーザーリスト...</div>
}

ご覧の通り、ページコンポーネント内で手動でレイアウトをインポートする必要は全くありません。Next.js が自動的にラップしてくれます。

部分レンダリングのパフォーマンス上の利点

ネストされたレイアウトのすごいところは 部分レンダリング (Partial Rendering) にあります。「アクティブユーザー」から「凍結ユーザー」に切り替えたとき:

  • 第1層レイアウト(トップナビゲーション、サイドバー)は再レンダリングされません
  • 第2層レイアウト(タブ)も再レンダリングされません
  • ページコンテンツのみが再レンダリングされます

これは2つのことを意味します:

  1. パフォーマンス向上。同じレイアウトコンポーネントを繰り返しレンダリングする必要がないため、ページ切り替えが速くなります。

  2. クライアント状態の保持。サイドバーに折りたたみ/展開の状態がある場合、ページを切り替えてもこの状態は失われません。

私が以前担当したプロジェクトでは、バックエンドのサイドバーに検索ボックスがあり、ユーザーが入力した後にページを切り替えると検索内容が消えてしまい、体験が悪かったです。ネストされたレイアウトを使用した後は、この問題は自然に解決しました――サイドバーコンポーネントがそもそも再レンダリングされないので、状態はもちろん保持されます。

実践例:多層ナビゲーション

実際のプロジェクトでは、3層や4層のナビゲーションが頻繁にあります。例えば:

  • バックエンド管理(第1層レイアウト:トップバー + サイドバー)
    • ユーザー管理(第2層レイアウト:ユーザー管理タブ)
      • アクティブユーザー(第3層:ページコンテンツ)
      • 凍結ユーザー(第3層:ページコンテンツ)
    • 注文管理(第2層レイアウト:注文管理タブ)
      • 待機中(第3層:ページコンテンツ)
      • 完了(第3層:ページコンテンツ)

ディレクトリ構造はこの階層関係に完全に対応しており、コードロジックが一目瞭然です:

app/(dashboard)/
├── layout.tsx              # 第1層: トップバー + サイドバー
├── users/
│   ├── layout.tsx          # 第2層: ユーザー管理エリア
│   ├── active/page.tsx     # 第3層: アクティブ
│   └── blocked/page.tsx    # 第3層: 凍結中
└── orders/
    ├── layout.tsx          # 第2層: 注文管理エリア
    ├── pending/page.tsx    # 第3層: 待機中
    └── completed/page.tsx  # 第3層: 完了

パフォーマンス最適化のヒント

ネストされたレイアウトはデフォルトで Server Component です。これは良いことで、レイアウトロジックがサーバー側でレンダリングされ、クライアントの JavaScript サイズを増加させません。

しかし、レイアウトに対話性(検索ボックスやドロップダウンメニューなど)がある場合、対話部分をクライアントコンポーネントとして抽出する必要があります:

// app/(dashboard)/layout.tsx - Server Component を維持
import SearchBar from '@/components/SearchBar' // Client Component

export default function DashboardLayout({ children }) {
  return (
    <div>
      <SearchBar /> {/* Client Component */}
      <main>{children}</main>
    </div>
  )
}

// components/SearchBar.tsx - Client Component
'use client'
import { useState } from 'react'

export default function SearchBar() {
  const [query, setQuery] = useState('')
  // ...対話ロジック
}

こうすることで、レイアウトのサーバーサイドの利点を維持しつつ、対話機能に影響を与えません。

もう一つのテクニックとして、各レイアウト階層に loading.tsx を設定してロード状態を表示することができます。ユーザー体験が大幅に向上します:

app/(dashboard)/
├── layout.tsx
├── loading.tsx             # 第1層ロード状態
└── users/
    ├── layout.tsx
    ├── loading.tsx         # 第2層ロード状態
    └── active/
        ├── page.tsx
        └── loading.tsx     # 第3層ロード状態

各階層が独自のローディングアニメーションを持つことができ、互いに干渉しません。

並行ルート(Parallel Routes)- 複数のページを同時にレンダリング

並行ルートが解決する問題

ダッシュボードページは通常、複数の独立したモジュールを同時に表示します。例えば:

  • 左上:データ分析パネル
  • 右上:チームメンバーパネル
  • 下部:最新通知パネル

各パネルのデータは個別に取得され、読み込み速度も異なります。従来の方法では、これらのコンテンツをすべて1つのページコンポーネントに書いていましたが、これには問題があります:あるパネルのデータ取得が遅いと、ページ全体がローディング状態で止まってしまいます。

並行ルートを使用すると、これらのモジュールを独立した「スロット (slot)」に分割でき、各スロットは独自のロード状態、エラー処理を持ち、条件に応じて選択的にレンダリングすることもできます。

基本構文

並行ルートの作成は簡単で、フォルダ名の先頭に @ を付けます:

app/dashboard/
├── layout.tsx
├── @analytics/page.tsx  # 分析スロット
├── @team/page.tsx       # チームスロット
├── @notifications/page.tsx  # 通知スロット
└── page.tsx             # デフォルトページ

@analytics@team@notifications、これらの @ で始まるフォルダがスロットです。

そして layout.tsx で、これらのスロットを props として受け取ることができます:

// app/dashboard/layout.tsx
export default function DashboardLayout({
  children,
  analytics,
  team,
  notifications,
}: {
  children: React.ReactNode
  analytics: React.ReactNode
  team: React.ReactNode
  notifications: React.ReactNode
}) {
  return (
    <div className="dashboard-grid">
      <div className="main-content">{children}</div>
      <div className="top-panels">
        <div className="panel">{analytics}</div>
        <div className="panel">{team}</div>
      </div>
      <div className="bottom-panel">{notifications}</div>
    </div>
  )
}

各スロットは独立したページコンポーネントに対応し、独自の loading と error 状態を持つことができます:

app/dashboard/
├── @analytics/
│   ├── page.tsx
│   ├── loading.tsx      # 分析パネルのロード状態
│   └── error.tsx        # 分析パネルのエラー処理
├── @team/
│   ├── page.tsx
│   ├── loading.tsx
│   └── error.tsx
└── @notifications/
    ├── page.tsx
    ├── loading.tsx
    └── error.tsx

これの利点は、分析パネルのデータが遅い場合、そのパネルだけがローディングアニメーションを表示し、他のパネルは正常に表示されることです。あるパネルがエラーになっても、ページ全体には影響しません。

実践例:条件付きレンダリング

並行ルートのもう一つの強力な機能は条件付きレンダリングです。例えば、チームパネルは管理者のみが見ることができます:

// app/dashboard/layout.tsx
import { auth } from '@/lib/auth'

export default async function DashboardLayout({
  analytics,
  team,
  notifications,
}) {
  const user = await auth()
  const isAdmin = user?.role === 'admin'

  return (
    <div className="dashboard-grid">
      <div>{analytics}</div>
      {isAdmin && <div>{team}</div>}  {/* 管理者のみ表示 */}
      <div>{notifications}</div>
    </div>
  )
}

default.tsx の役割

並行ルートにはハマりやすい落とし穴があります。ユーザーが /dashboard から /dashboard/settings にナビゲートしたとき、スロットに対応するページがない場合があります。Next.js は何をレンダリングすればいいか分からず、エラーを出します。

解決策は、フォールバックコンテンツとして default.tsx を作成することです:

// app/dashboard/@team/default.tsx
export default function Default() {
  return null  // またはプレースホルダーコンポーネントを返す
}

default.tsx があれば、スロットに対応するページがない場合、このフォールバックコンテンツがレンダリングされ、エラーを回避できます。

いつ並行ルートを使うか

正直なところ、並行ルートの使用頻度はルートグループやネストされたレイアウトほど高くありません。以下の場面に適しています:

  • ダッシュボードの多モジュール表示:複数の独立したデータパネルがあり、個別にロードする必要がある
  • A/B テスト:ユーザーグループに応じて異なるスロットコンテンツを表示する
  • 権限管理:ユーザーの権限に応じて特定のスロットを選択的にレンダリングする

しかし、単にコンテンツを上下に並べるだけで、独立したロード状態が必要ないなら、ページコンポーネントに直接書けばよく、並行ルートを使う必要はありません。

インターセプト・ルート(Intercepting Routes)- モーダルルートの実装

Instagram 風の体験

実を言うと、インターセプト・ルートは4つの機能の中で最も理解しにくいものです。私は Instagram の例を見るまで、公式ドキュメントが何を解決しようとしているのか全く分かりませんでした。

Instagram でフィードをスクロールし、ある画像をクリックすると、画像がモーダルで拡大表示され、同時に URL が /photo/abc123 に変わります。この時:

  • ページを更新すると、モーダルは消え、完全な画像ページが表示されます
  • URL を他の人に共有すると、その人が開くのは完全な画像ページであり、モーダルではありません
  • 閉じるボタンをクリックすると、モーダルが消え、フィードに戻ります

この体験の利点は明らかです:URL は共有可能で、更新してもコンテキストを失いませんが、スムーズなクライアントナビゲーションを維持します。従来の方法では、この効果を実現するのは非常に困難でした。

インターセプト・ルートはこの問題を解決するために設計されました。

基本構文

インターセプト・ルートは特殊なフォルダ命名規則を使用します:

  • (.) 同一階層のルートにマッチ
  • (..) 1つ上の階層のルートにマッチ
  • (..)(..) 2つ上の階層のルートにマッチ
  • (...) ルートディレクトリからのルートにマッチ

抽象的に聞こえますが、コードを見てみましょう:

app/
├── products/
│   ├── page.tsx                    # 商品一覧ページ
│   └── (..)product/[id]/page.tsx   # /product/123 をインターセプトし、モーダルとして表示
└── product/
    └── [id]/page.tsx               # 完全な商品詳細ページ

ユーザーが /products ページで商品リンク(<Link href="/product/123">)をクリックしたとき:

  • クライアントナビゲーション:インターセプトをトリガーし、(..)product/[id]/page.tsx(モーダル版)をレンダリングします
  • 直接アクセス /product/123 またはページ更新:インターセプトされず、通常の product/[id]/page.tsx(完全版)をレンダリングします

実践例:商品詳細モーダル

私が以前担当した EC プロジェクトには、商品一覧ページで商品をクリックすると、詳細を表示するモーダルがポップアップするという要件がありました。

ディレクトリ構造:

app/
├── (shop)/
│   └── products/
│       ├── page.tsx                     # 商品一覧
│       └── (..)product/[id]/page.tsx    # モーダル版
└── product/
    └── [id]/page.tsx                    # 完全ページ版

モーダル版のコード:

// app/(shop)/products/(..)product/[id]/page.tsx
'use client'
import { useRouter } from 'next/navigation'
import Modal from '@/components/Modal'
import ProductDetail from '@/components/ProductDetail'

export default function ProductModal({
  params
}: {
  params: { id: string }
}) {
  const router = useRouter()

  return (
    <Modal onClose={() => router.back()}>
      <ProductDetail id={params.id} />
    </Modal>
  )
}

完全ページ版:

// app/product/[id]/page.tsx
import ProductDetail from '@/components/ProductDetail'

export default function ProductPage({
  params
}: {
  params: { id: string }
}) {
  return (
    <div className="product-page">
      <ProductDetail id={params.id} />
    </div>
  )
}

商品詳細コンポーネント(ProductDetail)は再利用されており、外側を異なるコンテナでラップしているだけであることに注意してください。一方はモーダル、もう一方は完全なページです。

並行ルートとの組み合わせ

インターセプト・ルートを単独で使うと、状態管理の問題が発生することがあります。より良い方法は並行ルートと組み合わせることです:

app/(shop)/products/
├── layout.tsx
├── page.tsx
├── @modal/
│   ├── (..)product/[id]/page.tsx  # モーダルスロット
│   └── default.tsx                # デフォルトは空

レイアウトコンポーネント:

// app/(shop)/products/layout.tsx
export default function ProductsLayout({
  children,
  modal,
}: {
  children: React.ReactNode
  modal: React.ReactNode
}) {
  return (
    <>
      {children}
      {modal}
    </>
  )
}

// app/(shop)/products/@modal/default.tsx
export default function Default() {
  return null  // モーダルがないときは null を返す
}

これの利点は、モーダルとメインコンテンツが完全に分離され、状態管理がより明確になり、理解しやすくなることです。

注意事項

インターセプト・ルートにはいくつか注意すべき点があります:

  1. クライアントナビゲーション時のみインターセプト。ユーザーがブラウザのアドレスバーに直接 URL を入力したり、ページを更新したりした場合は、インターセプトはトリガーされません。

  2. 2つのバージョンをメンテナンスする必要がある。モーダル版と完全ページ版の両方を実装する必要があります。コンポーネントは再利用できますが、多少の重複コードは避けられません。

  3. パスのマッチング規則(..) はルートパスに基づいており、ファイルシステムのパスではありません。ルートグループを使用している場合、URL に影響しないため、パスのマッチングが想定と異なる可能性があります。

例えば:

app/
├── (shop)/products/
│   └── (..)product/[id]/page.tsx  # /product/[id] をインターセプト

ここで (..)/products から1つ上のルートディレクトリへ行くため、マッチするのは /product/[id] であり、/(shop)/product/[id] ではありません。

いつインターセプト・ルートを使うか

インターセプト・ルートは以下に適しています:

  • フォトギャラリー:リストをクリックして画像を拡大表示
  • 商品詳細:リストをクリックして商品をモーダルで表示
  • ログインポップアップ:ナビゲーションバーのログインボタンでモーダルログイン、でも /login ルートにも直接アクセス可能

適さない場合:

  • 単純なポップアップ(URL 変更が不要な場合)
  • ディープリンクを必要としないシナリオ

総じて、インターセプト・ルートは非常に強力な機能ですが、最も複雑でもあります。Instagram のような要件がない場合は、無理に使う必要はありません。

総合実践 - EC プロジェクトの完全なディレクトリ構造

4つの機能を解説しましたので、これらを組み合わせて実際の EC プロジェクトのディレクトリ構成を完成させましょう。

プロジェクト要件

典型的な EC プロジェクトには通常以下のモジュールがあります:

フロントエンド(ユーザー向け):

  • トップページ、会社概要(マーケティングページ)
  • 商品一覧、商品詳細(モーダル対応)
  • カート、チェックアウト

バックエンド(管理者向け):

  • ダッシュボード(多モジュール表示:データ分析、チーム、通知)
  • ユーザー管理(アクティブ、凍結)
  • 注文管理(待機中、完了)

認証:

  • ログイン、登録(独立レイアウト、ナビゲーションバーなし)

最終的なディレクトリ構造

app/
├── layout.tsx                          # ルートレイアウト

├── (marketing)/                        # フロントエンドルートグループ
│   ├── layout.tsx                      # フロントエンドレイアウト(ヘッダーナビ + フッター)
│   ├── page.tsx                        # / (トップページ)
│   ├── about/page.tsx                  # /about
│   └── pricing/page.tsx                # /pricing

├── (shop)/                             # EC 機能グループ
│   ├── layout.tsx                      # EC レイアウト
│   ├── products/
│   │   ├── layout.tsx                  # 商品一覧レイアウト(モーダルスロット含む)
│   │   ├── page.tsx                    # /products
│   │   └── @modal/
│   │       ├── (..)product/[id]/page.tsx  # 商品詳細モーダル
│   │       └── default.tsx
│   ├── cart/page.tsx                   # /cart
│   └── checkout/page.tsx               # /checkout

├── product/
│   └── [id]/page.tsx                   # /product/123 (完全ページ)

├── (dashboard)/                        # バックエンドルートグループ
│   ├── layout.tsx                      # バックエンドレイアウト(サイドバー + トップバー)
│   ├── dashboard/
│   │   ├── layout.tsx                  # ダッシュボードレイアウト(並行ルート)
│   │   ├── page.tsx                    # /dashboard (デフォルトコンテンツ)
│   │   ├── @analytics/
│   │   │   ├── page.tsx                # データ分析モジュール
│   │   │   ├── loading.tsx
│   │   │   └── default.tsx
│   │   ├── @team/
│   │   │   ├── page.tsx                # チームモジュール
│   │   │   ├── loading.tsx
│   │   │   └── default.tsx
│   │   └── @notifications/
│   │       ├── page.tsx                # 通知モジュール
│   │       ├── loading.tsx
│   │       └── default.tsx
│   ├── users/
│   │   ├── layout.tsx                  # ユーザー管理第2層レイアウト
│   │   ├── active/page.tsx             # /users/active
│   │   └── blocked/page.tsx            # /users/blocked
│   └── orders/
│       ├── layout.tsx                  # 注文管理第2層レイアウト
│       ├── pending/page.tsx            # /orders/pending
│       └── completed/page.tsx          # /orders/completed

└── (auth)/                             # 認証ルートグループ
    ├── layout.tsx                      # シンプルレイアウト(ナビなし)
    ├── login/page.tsx                  # /login
    └── register/page.tsx               # /register

従来構造との比較

従来のフラット構造と新構造を比較してみましょう:

次元従来のフラット構造ルートグループ + ネストされたレイアウト
ファイル検索100+ ファイルから命名プレフィックスに頼って探す業務モジュールごとにグループ化され一目瞭然
レイアウト管理各ページで手動インポート自動継承、一箇所の修正で全体に反映
チーム連携全員が同じディレクトリで作業し競合しやすいチーム/モジュールごとに異なるフォルダ
URL の明瞭さ競合回避のためプレフィックスが必要(dashboard-users-active)URL はシンプル(/users/active)、ディレクトリ構造は明確
モーダル体験クライアント状態管理、更新で状態喪失ルート駆動、更新で完全ページ表示
パフォーマンスページ切り替えでレイアウト再レンダリング部分レンダリング、変化部分のみ更新

具体的なメリット

新構造を使用した私たちのチームの実際の結果:

  1. ファイル検索速度50%向上。以前はページを探すのに何ページもめくっていましたが、今は対応するルートグループに行くだけです。

  2. レイアウト修正効率向上。以前はバックエンドのサイドバーを変更するのに20ファイルをチェックしていましたが、今は (dashboard)/layout.tsx 1つを変更するだけで、すべてのバックエンドページに反映されます。

  3. コード競合60%削減。マーケティングチームは (marketing)、プロダクトチームは (shop)、バックエンドチームは (dashboard) と、各自の領域で作業しています。

  4. 新人の立ち上がりが早い。新しいインターンはディレクトリ構造を見ただけで、私が説明するまでもなくプロジェクト全体のアーキテクチャを5分で理解しました。

いくつかの実用的なアドバイス

  1. 一度に再構築しない。1つのモジュール(例えばバックエンド管理)を選んで試験的に行い、有効性を検証してからプロジェクト全体に広げてください。

  2. ルートグループの命名に意味を持たせる。私たちは (marketing)(shop)(dashboard)(auth) を使用しており、チームメンバーは一目で理解できます。

  3. ドキュメントが重要。プロジェクトの README に「ディレクトリ構造説明」の章を追加し、各ルートグループの役割を解説して、新人が理解しやすくしてください。

  4. TypeScript との連携。ルートグループとネストされたレイアウトは、TypeScript のパスマッピング(@/*)と連携すると、コードの整理がより明確になります。

// tsconfig.json
{
  "compilerOptions": {
    "paths": {
      "@/*": ["./src/*"],
      "@/components/*": ["./src/components/*"],
      "@/app/*": ["./src/app/*"]
    }
  }
}

ベストプラクティスと注意事項

ルートグループ命名規則

ルートグループの命名はコードのメンテナンス性に直結します。私たちのチームの規則は以下の通りです:

推奨

  • (marketing) - マーケティング関連ページ
  • (dashboard) または (admin) - バックエンド管理
  • (auth) - 認証関連
  • (team-xxx) - チーム別にグループ化する場合
  • (feature-xxx) - 機能別にグループ化する場合

非推奨

  • (group1)(group2) - 無意味な名前
  • (temp)(test) - 一時的な名前
  • (marketing-and-sales-pages) のような長すぎる名前 - シンプルでない

ルートグループの名前は開発者が見るためのものであり、URL には現れないことを覚えておいてください。チームメンバーが一目で理解できる名前を付けましょう。

ルート競合を避ける

異なるルートグループに同じルートパスを持たせることはできません。これは最も踏みやすい落とし穴です:

❌ エラー例:
app/
├── (marketing)/about/page.tsx  # URL: /about
└── (shop)/about/page.tsx       # URL: /about - 競合!

解決策:

  1. ルート計画:開始前にルートマップを描き、すべてのパスが一意であることを確認する
  2. プレフィックス追加:どうしても2つの about が必要な場合、一方にプレフィックスを追加する(例:/about-us/about-product
  3. ディレクトリ調整:競合するルートを異なる階層に配置する

並行ルートの使用時期

並行ルートは必須ではありません。これらのシーンでのみ使用してください:

適用シナリオ

  • ダッシュボードの複数の独立したデータパネル
  • 独立したロード状態が必要な並列コンテンツ
  • ユーザー権限に基づいて条件付きレンダリングするモジュール
  • 異なるコンテンツバリエーションの A/B テスト

非適用シナリオ

  • 単純な上下配置(ページコンポーネントに直接書けばよい)
  • 独立したロード状態を必要としないコンテンツ
  • 静的なレイアウトエリア

並行ルートを使うべきか迷ったら、おそらく必要ありません。これは高度な機能であり、ほとんどのプロジェクトはルートグループとネストされたレイアウトで十分です。

インターセプト・ルートの限界

インターセプト・ルートは強力ですが、いくつかの注意点があります:

  1. クライアントナビゲーション時のみインターセプト。直接 URL 入力や更新ではトリガーされません。

  2. 2つのバージョンのメンテナンス。モーダル版と完全ページ版の両方を実装する必要があります。コンポーネントは再利用できますが、メンテナンスコストはかかります。

  3. パスのマッチングはファイルシステムではなくルートベース。ルートグループは URL に影響しないため、(..) はフォルダパスではなく URL パスにマッチします。混同しやすいです。

URL の変更が不要な場合や、ディープリンクが不要な場合は、通常のクライアントモーダルで十分です。

パフォーマンス最適化テクニック

  1. レイアウトコンポーネントは Server Component を維持。デフォルトでレイアウトは Server Component です。可能な限りこの特性を維持し、対話が必要な部分のみ Client Component として抽出してください。

  2. loading.tsx の使用。各ルート階層に loading.tsx を追加し、フレンドリーなロード状態を提供すると、ユーザー体験が大幅に向上します。

  3. Suspense の適切な使用。loading.tsx と Suspense を組み合わせることで、よりきめ細かいストリーミングレンダリングを実現できます。

  4. 過度なネストを避ける。レイアウト階層は4層を超えないようにしてください。深すぎると複雑さが増し、メンテナンスに不利です。

移行戦略

Pages Router から App Router へ移行する場合の提案:

  1. 増分移行:app ディレクトリと pages ディレクトリは共存できます。モジュールごとに移行し、一度にすべて変更しないでください。

  2. まずレイアウトを移行:ルートグループとネストされたレイアウトでレイアウトロジックを再構築します。これは最も効果が明確な部分です。

  3. 次にデータ取得を移行:getServerSideProps を fetch に、getStaticProps を Server Component に変更します。

  4. 最後にルートを移行:getStaticPaths を generateStaticParams に変更します。

  5. カナリアリリース:feature flag で新旧ルートの切り替えを制御し、問題が発生したらすぐにロールバックできるようにします。

チームコラボレーションの提案

  1. ディレクトリ構造の規範を策定。README や Wiki に、各ルートグループの役割、命名規則、新しいページを追加するフローを明記してください。

  2. コードレビューの重点。PR レビュー時は、ルート規範に従っているか、ルート競合がないか、レイアウトが正しくネストされているかを重点的にチェックしてください。

  3. ESLint ルールの使用。ルートグループの命名や特定のパスを禁止するなどの ESLint ルールを設定できます。

  4. 定期的なリファクタリング。各イテレーション終了後、ディレクトリ構造を整理し、不要なページを削除し、不合理なルートグループの名前を変更する時間を設けてください。

デバッグのヒント

ルートグループとネストされたレイアウトのデバッグは時に厄介です。いくつかのヒントを共有します:

  1. React DevTools を見る。ブラウザ開発ツールの React タブで完全なコンポーネントツリーを確認し、レイアウトが正しくネストされているか確認できます。

  2. console.log を使う。レイアウトコンポーネントに console.log を追加し、重複レンダリングされていないか、ナビゲーションごとにトリガーされていないか確認します。

  3. Network タブをチェック。どのリクエストがサーバー側で発火し、どれがクライアント側かを確認し、データ取得ロジックが正しいか検証します。

  4. Next.js の出力を読む。開発モードでは Next.js はルート競合やレイアウト欠落などの有用な情報を多数出力します。ターミナルの警告とエラーに注意してください。

結論

冒頭のシナリオに戻りましょう:午前1時、120個のフォルダを前に呆然とし、ページを探すのに5分かかる状況。

もしあなたの Next.js プロジェクトが同じ問題に直面しているなら、この記事で紹介した4つの機能が役立つはずです:

ルートグループは、ビジネスロジックやチームの役割分担ごとにルートをグループ化し、ディレクトリ構造を一目瞭然にしつつ、URL を冗長にしません。

ネストされたレイアウトは、レイアウトの継承を自動的に処理し、各ページでレイアウトコンポーネントを手動でインポートする必要をなくし、1つのファイルの修正だけで全体に反映させます。

並行ルートは、複数の独立したモジュールを同時にレンダリングし、各モジュールが独自のロード状態やエラー処理を持つことを可能にし、ダッシュボードのようなマルチパネルシナリオに適しています。

インターセプト・ルートは、Instagram のようなモーダル体験を実現します――URL は共有可能で、更新してもコンテキストを失わず、スムーズなクライアントナビゲーションを維持します。

私のアドバイスは、小さく始めることです。1つのモジュール(例えばバックエンド管理)を選んで試してみ、ルートグループとネストされたレイアウトで再構築し、効果を確認してください。実行可能であれば、プロジェクト全体に広げてください。

一度にすべてのコードを再構築しないでください。リスクが高すぎます。増分移行で、一歩ずつ進めれば、問題が起きても簡単にロールバックできます。

最後に、完全な EC プロジェクトのディレクトリ構造テンプレートを置いておきます。直接参照したりコピーして使用したりできます。質問があれば、コメントで議論しましょう。

あなたの Next.js プロジェクトのディレクトリが混沌から解放され、メンテナンスが楽になることを祈っています!

Next.js 大規模プロジェクトディレクトリ構造リファクタリング完全フロー

ルートグループ、ネストされたレイアウト、並行ルート、インターセプト・ルートを使用して混沌としたディレクトリ構造を再構築する

⏱️ Estimated time: 8 hr

  1. 1

    Step1: 既存のディレクトリ構造を分析する

    現在の問題を評価:
    • フォルダ数を統計する(50以上なら再構築を推奨)
    • ルート競合箇所を特定する
    • 重複したレイアウトコードを見つける
    • チーム連携の痛点を記録する

    機能エリアを識別:
    • マーケティングページ(トップ、About、価格)
    • EC ページ(商品、カート、注文)
    • バックエンド管理(ユーザー、注文、設定)
  2. 2

    Step2: ルートグループを作成して機能エリアを分割する

    丸括弧を使用してルートグループを作成:
    • (marketing):マーケティング関連ページ
    • (shop):EC 関連ページ
    • (dashboard):バックエンド管理ページ

    注意事項:
    • ルートグループ名は URL に影響しない
    • 同じ URL が複数のグループに現れてはいけない
    • 各ルートグループは独自の layout.js を持てる
  3. 3

    Step3: ネストされたレイアウト階層を設計する

    UI 階層に基づいて設計:
    • 第1層:app/layout.js(サイト全体共通)
    • 第2層:機能エリア layout.js(例:shop/layout.js)
    • 第3層:詳細ページ layout.js(例:shop/products/[id]/layout.js)

    実装のポイント:
    • 各層はその層に特有の UI 要素のみを追加する
    • 子レイアウトは親レイアウトを自動的に継承する
    • ページ切り替え時に layout は再レンダリングされない
  4. 4

    Step4: 並行ルートを実装する(必要な場合)

    @ 記号を使用してスロットを作成:
    • @modal:モーダルスロット
    • @sales, @orders:ダッシュボードの独立モジュール

    layout.js で受け取る:
    • export default function Layout({ children, modal })
    • JSX でレンダリング:{modal}

    各スロットは独自の loading.js と error.js を持てる
  5. 5

    Step5: インターセプト・ルートを実装する(モーダルが必要な場合)

    インターセプト・ルートを作成:
    • @modal の下に (.)photos/[id]/page.js を作成
    • photos/[id]/page.js(完全ページ)を作成

    構文:
    • (.):同一階層のルートをインターセプト
    • (..):1つ上の階層のルートをインターセプト
    • (...):ルートディレクトリからのルートをインターセプト

    default.js を作成して null を返す必要がある
  6. 6

    Step6: テストと検証

    検証ポイント:
    • すべてのルートが正常か
    • レイアウトが正しくネストされているか
    • ページ切り替えがスムーズか
    • モーダルが正常に動作するか

    デバッグツール:
    • React DevTools でコンポーネントツリーを確認
    • console.log でレンダリング回数を確認
    • Network tab でリクエストを確認
    • Next.js ターミナル出力で警告を確認

FAQ

いつルートグループを使用すべきですか?
プロジェクトのフォルダが50を超える場合、または異なる機能エリアに異なるルートレイアウトを設定する必要がある場合に、ルートグループを使用すべきです。ルートグループは特に複数のチームが協力するプロジェクトに適しており、Git の競合率を大幅に下げ、ディレクトリ構造をより明確にすることができます。
ルートグループは URL に影響しますか?
いいえ。ルートグループは丸括弧で命名され(例:(marketing))、Next.js は括弧内の名前を無視するため、URL は変わりません。例えば (marketing)/about/page.js の URL は /marketing/about ではなく /about のままです。
ネストされたレイアウトはパフォーマンスに影響しますか?
いいえ、むしろパフォーマンスを向上させます。Next.js のネストされたレイアウトは、子ルートへの切り替え時に再レンダリングされず、最下層の page.js のみが再読み込みされます。つまり、layout 内に状態(サイドバーのスクロール位置など)を保存でき、ページ切り替え時にその状態が維持されます。
並行ルートと通常のコンポーネントの違いは何ですか?
並行ルートの各スロットは独立したページフラグメントであり、独自の loading.js と error.js を持ち、並行して読み込まれ、互いに影響しません。一方、通常のコンポーネントはすべてのデータ読み込みが完了するのを待ってからレンダリングする必要があり、1つのコンポーネントのエラーがページ全体に影響する可能性があります。
インターセプト・ルートの構文はどう選べばいいですか?
(.) は同一階層のルートをインターセプトし(@modal と photos が app 直下にある場合など)、(..) は1つ上の階層をインターセプトし、(...) はルートディレクトリからインターセプトします。確信が持てない場合は、まず (...) でルートからインターセプトし、動作確認後にディレクトリ構造に合わせて調整することをお勧めします。
ルート競合はどうすれば避けられますか?
機能ごとにルートグループを使用し、同じ URL が複数のグループに現れないようにしてください。どうしても同じ名前のルートが必要な場合は、URL に実際のディレクトリ階層(括弧なし)を追加するか、ルートのいずれかをリネームしてください。
大規模プロジェクトのリファクタリングにはどれくらい時間がかかりますか?
プロジェクトの規模によります。中規模プロジェクト(50-100ページ)は1-2週間、大規模プロジェクトは1ヶ月かかる可能性があります。まずは1つのモジュールを選んで試験的に行い、有効性を検証してからプロジェクト全体に広げる「増分移行」をお勧めします。リスクが小さくなります。

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

コメント

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

関連記事