Next.js: App Router ファイルシステムルーティング

最終更新:2026-08-26

ファイルシステムルーティングは、図書館での本の分類方法のようなものです。ファイル名が URL パスを決定し、ディレクトリ構造がルーティングテーブルとして機能するため、手動設定は一切不要です。

1. 学ぶこと



2. テクニカルマネージャーの実話

(1) ペインポイント: 手動ルーティング設定が混乱している

Bob は e コマース企業のテクニカルリードで、彼のチームは 50 ページの React SPA をメンテナンスしています。新しいページを追加するたびに、開発者は3つのファイルを手動で設定しなければなりません:

「私たちのルーティング設定テーブル routes.js は 300 行あります。先週、Xiao Ming が新しい商品詳細ページを追加しましたが、ルーティングテーブルに path: '/products/:id' を登録するのを忘れ、ページ公開後まる2時間も 404 エラーに気づきませんでした。」

Bob のチームが遭遇したルーティング管理の問題:

問題 影響 頻度
ルーティング設定の欠落 デプロイ後に 404 月に 1〜2 回
ネストルートの手動処理 複雑なコード、ルート階層の混乱 新規ページごと
404/500 エラーページの散在 一貫性がなく、ユーザー体験が悪い 複数ページ
API ルートの個別メンテナンス フロントエンドとバックエンドで別々のルート API ごと

(2) Next.js ファイルシステムルーティングの解決策

ファイル名が URL になり、ディレクトリ構造がルーティングテーブルになります。react-router-dom のルーティング設定はもう不要です。

TEXT 📖 参照専用
src/app/
├── page.tsx                    # → /
├── about/
│   └── page.tsx                # → /about
├── products/
│   ├── page.tsx                # → /products
│   └── [id]/
│       ├── page.tsx            # → /products/1, /products/2
│       └── reviews/
│           └── page.tsx        # → /products/1/reviews
└── api/
    └── products/
        └── route.ts            # → /api/products (GET/POST)

(3) 効果

次元 導入前 (React Router) 導入後 (ファイルシステムルーティング)
ページ追加の手順 3ステップ (コンポーネント作成 + ルーティング設定 + インポート) 1ステップ (ファイル作成)
ルーティング設定のメンテナンス 300行のルーティングテーブル ゼロ行、ディレクトリが自動的に決定
公開時の 404 エラー 月に 1〜2 回 0 回
404/500 エラーの統一管理 手動インポート ファイル規約が自動的に適用


3. ファイルシステムルーティングの原則

(1) ファイル名が URL になる

100%
graph TB
    subgraph "src/app/ ディレクトリ構造"
        A[page.tsx] --> B[/]
        C[about/page.tsx] --> D[/about]
        E[products/page.tsx] --> F[/products]
        G[products/promo/page.tsx] --> H[/products/promo]
        I[products/id/page.tsx] --> J[/products/:id]
        K[dashboard/settings/page.tsx] --> L[/dashboard/settings]
    end

    style A fill:#d4edda
    style C fill:#d4edda
    style E fill:#d4edda
ファイル名 対応する URL 説明
app/page.tsx / ホーム
app/about/page.tsx /about 静的ページ
app/blog/page.tsx /blog ブログ一覧
app/blog/[id]/page.tsx /blog/1 動的ルーティング
app/dashboard/settings/page.tsx /dashboard/settings ネスト多段

(2) 6つの主要ファイル規約

ファイル名 目的 必須? レンダリング動作
page.tsx ページコンポーネント (UI コンテンツ) サーバーコンポーネント (デフォルト)
layout.tsx レイアウトコンテナ (状態保持) ✅ ルートレイアウト ナビゲーション中に再マウントしない
loading.tsx ロードスケルトン画面 任意 Suspense フォールバック
error.tsx エラーバウンダリ UI 任意 子コンポーネントのエラーをキャッチ
not-found.tsx 404 ページ 任意 notFound() トリガー
route.ts API エンドポイント 任意 サーバーサイド実行

▶ サンプル: 最初のページを作成

💻 出力:

TEXT 📖 参照専用
Diagram: page.tsx; /; about/page.tsx; /about; products/page.tsx; /products.
TSX
// ============================================
// /about ページを作成
// ファイル: src/app/about/page.tsx
// ファイルを作成するだけで、ルートが自動登録される
// ============================================

export default function AboutPage() {
  return (
    <div className="max-w-2xl mx-auto p-8">
      <h1 className="text-3xl font-bold">About TaskFlow</h1>
      <p className="mt-4 text-gray-600 leading-relaxed">
        TaskFlow is a collaborative project management platform that helps
        teams plan, track, and deliver projects efficiently. Built with
        Next.js 16 and React 19, it provides real-time updates, seamless
        collaboration, and enterprise-grade security.
      </p>
      <div className="mt-8 grid grid-cols-3 gap-4">
        <div className="p-4 bg-blue-50 rounded-lg text-center">
          <div className="text-2xl font-bold text-blue-600">10K+</div>
          <div className="text-sm text-gray-500">Active Users</div>
        </div>
        <div className="p-4 bg-green-50 rounded-lg text-center">
          <div className="text-2xl font-bold text-green-600">50K+</div>
          <div className="text-sm text-gray-500">Projects</div>
        </div>
        <div className="p-4 bg-purple-50 rounded-lg text-center">
          <div className="text-2xl font-bold text-purple-600">99.9%</div>
          <div className="text-sm text-gray-500">Uptime</div>
        </div>
      </div>
    </div>
  );
}
💻 出力:

TEXT 📖 参照専用
Content: About TaskFlow | TaskFlow is a collaborative project | 10K+ | Active Users
💻 出力:

TEXT 📖 参照専用
http://localhost:3000/about にアクセスすると表示:
About TaskFlow
TaskFlow is a collaborative project management platform...
10K+  Active Users | 50K+  Projects | 99.9%  Uptime


4. 動的ルーティング

(1) 単一パラメータ動的ルーティング [id]

100%
graph LR
    A[app/products] --> B[page.tsx → /products]
    A --> C[[id]]
    C --> D[page.tsx → /products/1]
    C --> E[/products/2]

    style B fill:#d4edda
    style D fill:#cce5ff
ファイルパターン URL の例 params の値
app/blog/[slug]/page.tsx /blog/hello-world { slug: 'hello-world' }
app/products/[id]/page.tsx /products/42 { id: '42' }
app/users/[userId]/settings/page.tsx /users/123/settings { userId: '123' }

▶ サンプル: 動的商品詳細ページ

💻 出力:

TEXT 📖 参照専用
Diagram: app/products; page.tsx → /products; [id; page.tsx → /products/1; /products/2.
TSX
// ============================================
// 商品詳細ページ — 動的ルーティング [id]
// ファイル: src/app/products/[id]/page.tsx
// ============================================

// Next.js 16 では、params は Promise (非同期) です
export default async function ProductPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const product = await fetch(`https://fakestoreapi.com/products/${id}`);

  return (
    <div className="container mx-auto p-8">
      <div className="flex gap-8">
        <img
          src={product.image}
          alt={product.title}
          className="w-80 h-80 object-contain"
        />
        <div>
          <h1 className="text-2xl font-bold">{product.title}</h1>
          <p className="text-xl text-green-600 font-bold mt-2">
            ${product.price}
          </p>
          <p className="text-sm text-gray-500 mt-1">
            Category: {product.category}
          </p>
          <p className="mt-4 text-gray-700">{product.description}</p>
        </div>
      </div>
    </div>
  );
}
💻 出力:

TEXT 📖 参照専用
Server component fetches and renders data.
💻 出力:

TEXT 📖 参照専用
/products/1 にアクセスすると表示:
Fjallraven - Foldsack No. 1 Backpack
$109.95
Category: men's clothing
Your perfect pack for day trips and hikes...

(2) キャッチオールルート [...slug]

100%
graph TB
    A[app/docs] --> B[[...slug]]
    B --> C[page.tsx]
    C --> D[/docs/getting-started]
    C --> E[/docs/guides/installation]
    C --> F[/docs/api/authentication/overview]

    style C fill:#cce5ff
ファイルパターン URL params.slug
app/docs/[...slug]/page.tsx /docs 404 (レベル1が必要)
app/docs/[...slug]/page.tsx /docs/getting-started ['getting-started']
app/docs/[...slug]/page.tsx /docs/guides/installation ['guides', 'installation']
app/docs/[...slug]/page.tsx /docs/a/b/c ['a', 'b', 'c']

▶ サンプル: キャッチオールドキュメントページ

💻 出力:

TEXT 📖 参照専用
Diagram: app/docs; [...slug; page.tsx; /docs/getting-started; /docs/guides/installation; /docs/api/authentication/overview.
TSX
// ============================================
// キャッチオールルーティング — 多段ドキュメントページ
// ファイル: src/app/docs/[...slug]/page.tsx
// ============================================

export default async function DocsPage({
  params,
}: {
  params: Promise<{ slug: string[] }>;
}) {
  const { slug } = await params;

  return (
    <div className="container mx-auto p-8">
      <nav className="text-sm text-gray-500 mb-4">
        Home / Docs / {slug.join(" / ")}
      </nav>
      <h1 className="text-3xl font-bold">Docs: {slug.join(" > ")}</h1>
      <div className="mt-8 p-6 bg-yellow-50 border border-yellow-200 rounded-lg">
        <p className="font-medium">You are viewing documentation for:</p>
        <ul className="mt-2 list-disc list-inside">
          {slug.map((segment, index) => (
            <li key={index}>
              Level {index + 1}: <code className="bg-gray-100 px-2 py-0.5 rounded">{segment}</code>
            </li>
          ))}
        </ul>
      </div>
      <p className="mt-4 text-gray-600">
        Total depth: {slug.length} levels
      </p>
    </div>
  );
}
💻 出力:

TEXT 📖 参照専用
DocsPage renders its UI.
💻 出力:

TEXT 📖 参照専用
/docs/getting-started/installation にアクセスすると表示:
Home / Docs / getting-started / installation
Docs: getting-started > installation

You are viewing documentation for:
  - Level 1: getting-started
  - Level 2: installation
Total depth: 2 levels

(3) オプショナルキャッチオール [[...catchAll]]

100%
graph TB
    A[app/categories] --> B[[[...catchAll]]]
    B --> C[page.tsx]
    C --> D[/categories]
    C --> E[/categories/electronics]
    C --> F[/categories/electronics/phones]

    style C fill:#d4edda
    style D fill:#d4edda
ファイルパターン URL params.catchAll
app/categories/[[...catchAll]]/page.tsx /categories undefined
app/categories/[[...catchAll]]/page.tsx /categories/electronics ['electronics']
app/categories/[[...catchAll]]/page.tsx /categories/electronics/phones ['electronics', 'phones']

▶ サンプル: オプショナルキャッチオールカテゴリページ

💻 出力:

TEXT 📖 参照専用
Diagram: app/categories; [[...catchAll; page.tsx; /categories; /categories/electronics; /categories/electronics/phones.
TSX
// ============================================
// オプショナルキャッチオール — カテゴリ閲覧ページ
// ファイル: src/app/categories/[[...categories]]/page.tsx
// /categories と /categories/electronics の両方に有効
// ============================================

export default async function CategoriesPage({
  params,
}: {
  params: Promise<{ categories?: string[] }>;
}) {
  const { categories } = await params;

  const allProducts = await fetch("https://fakestoreapi.com/products");
  const categoriesList = [...new Set(allProducts.map(p => p.category))];

  const filteredProducts = categories
    ? allProducts.filter(p => p.category === categories[0])
    : allProducts;

  return (
    <div className="container mx-auto p-8">
      <h1 className="text-3xl font-bold mb-6">
        {categories ? `Category: ${categories[0]}` : "All Categories"}
      </h1>

      {!categories && (
        <div className="flex gap-2 mb-8 flex-wrap">
          {categoriesList.map(cat => (
            <a
              key={cat}
              href={`/categories/${encodeURIComponent(cat)}`}
              className="px-4 py-2 bg-gray-100 rounded-full hover:bg-blue-100"
            >
              {cat}
            </a>
          ))}
        </div>
      )}

      <div className="grid grid-cols-4 gap-6">
        {filteredProducts.slice(0, 8).map(p => (
          <div key={p.id} className="border rounded-lg p-4 hover:shadow-lg">
            <img src={p.image} alt={p.title} className="h-40 mx-auto" />
            <p className="mt-2 font-medium text-sm truncate">{p.title}</p>
            <p className="text-green-600 font-bold">${p.price}</p>
          </div>
        ))}
      </div>
    </div>
  );
}
💻 出力:

TEXT 📖 参照専用
Server-side data fetch renders list of items.
💻 出力:

TEXT 📖 参照専用
/categories にアクセスすると表示:
全カテゴリボタン (electronics, jewelery, men's clothing, women's clothing)

/categories/electronics にアクセスすると表示:
Category: electronics
electronics のみ表示 (8 商品カード)


5. loading.tsx と error.tsx

(1) loading.tsx — ロードスケルトン画面

100%
graph LR
    A[ユーザーがナビゲート] --> B[loading.tsx<br/>スケルトン表示]
    B --> C[page.tsx<br/>データ準備完了]
    C --> D[完全なページ]

    style B fill:#f8d7da
    style C fill:#d4edda

▶ サンプル: ロードスケルトン画面

💻 出力:

TEXT 📖 参照専用
Diagram: User Navigation; loading.tsx Wireframe Display; page.tsx Data Ready; Full Page.
TSX
// ============================================
// ロードスケルトン画面 — ページデータ取得中に表示
// ファイル: src/app/products/loading.tsx
// ============================================

export default function ProductsLoading() {
  return (
    <div className="container mx-auto p-8">
      <div className="h-8 w-48 bg-gray-200 rounded animate-pulse mb-6" />
      <div className="grid grid-cols-4 gap-6">
        {Array.from({ length: 8 }).map((_, i) => (
          <div key={i} className="border rounded-lg p-4">
            <div className="h-40 bg-gray-200 rounded animate-pulse" />
            <div className="h-4 bg-gray-200 rounded mt-2 animate-pulse" />
            <div className="h-4 w-16 bg-gray-200 rounded mt-2 animate-pulse" />
          </div>
        ))}
      </div>
    </div>
  );
}
💻 出力:

TEXT 📖 参照専用
Renders a list of items using .map().
💻 出力:

TEXT 📖 参照専用
/products にアクセス中、データ読み込み完了まで表示:
8 個のグレーのプレースホルダーカード (パルスアニメーション付き)
データの読み込みが完了すると、プレースホルダーの内容が自動的に実際の内容に置き換わる
ちらつきなし、ホワイトスクリーンなし、スムーズな遷移

(2) error.tsx — エラーバウンダリ

▶ サンプル: エラーページ

💻 出力:

TEXT 📖 参照専用
The page renders as described above, with the UI updating based on the described behavior.
TSX
// ============================================
// エラーバウンダリ — 子コンポーネントのエラーをキャッチ
// ファイル: src/app/products/error.tsx
// ============================================

'use client';

export default function ProductsError({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  return (
    <div className="container mx-auto p-8 text-center">
      <div className="max-w-md mx-auto">
        <h2 className="text-2xl font-bold text-red-600 mb-4">
          Something went wrong!
        </h2>
        <p className="text-gray-600 mb-6">
          {error.message || "Failed to load products. Please try again."}
        </p>
        <button
          onClick={reset}
          className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
        >
          Try Again
        </button>
      </div>
    </div>
  );
}
💻 出力:

TEXT 📖 参照専用
Content: Something went wrong! | Try Again
💻 出力:

TEXT 📖 参照専用
商品ページのデータ取得が失敗した場合、表示:
Something went wrong!
Failed to load products. Please try again.
[Try Again] ボタン → クリックで自動リトライ

(3) not-found.tsx — 404 ページ



6. Route Handlers (API ルート)

(1) route.ts の基本的な使い方

100%
graph LR
    A[クライアントリクエスト] --> B[route.ts]
    B --> C[GET /api/products]
    B --> D[POST /api/products]
    B --> E[PUT /api/products/:id]
    B --> F[DELETE /api/products/:id]

    style B fill:#cce5ff
HTTP メソッド エクスポート関数 目的
GET export async function GET() データ読み取り
POST export async function POST() データ作成
PUT export async function PUT() データ更新
DELETE export async function DELETE() データ削除
PATCH export async function PATCH() 部分更新


7. 完全な例: e コマース商品閲覧システム

TSX
// ============================================
// 総合例: e コマース商品閲覧システム
// ページ、レイアウト、ローディング、エラー、API ルーティングをカバー
// ============================================

// src/app/layout.tsx — ルートレイアウト
export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body className="bg-gray-50">
        <nav className="bg-white shadow-sm p-4">
          <a href="/" className="text-xl font-bold text-blue-600">
            ShopHub
          </a>
        </nav>
        <main>{children}</main>
      </body>
    </html>
  );
}

// src/app/page.tsx — ホーム
export default function HomePage() {
  return (
    <div className="container mx-auto p-8 text-center">
      <h1 className="text-4xl font-bold">Welcome to ShopHub</h1>
      <p className="mt-4 text-gray-600">
        Browse our collection of amazing products.
      </p>
      <a
        href="/products"
        className="inline-block mt-6 px-8 py-3 bg-blue-600 text-white rounded-lg"
      >
        Browse Products
      </a>
    </div>
  );
}

// src/app/products/page.tsx — 商品一覧
export default async function ProductsPage() {
  const products = await fetch(
    "https://fakestoreapi.com/products"
  );

  return (
    <div className="container mx-auto p-8">
      <h1 className="text-3xl font-bold mb-6">All Products</h1>
      <div className="grid grid-cols-4 gap-6">
        {products.map(p => (
          <a
            key={p.id}
            href={`/products/${p.id}`}
            className="border rounded-lg p-4 bg-white hover:shadow-lg"
          >
            <img
              src={p.image}
              alt={p.title}
              className="h-40 mx-auto"
            />
            <p className="mt-2 font-medium text-sm">{p.title}</p>
            <p className="text-green-600 font-bold mt-1">${p.price}</p>
          </a>
        ))}
      </div>
    </div>
  );
}

// src/app/products/[id]/page.tsx — 商品詳細
export default async function ProductDetailPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const product = await fetch(
    `https://fakestoreapi.com/products/${id}`
  );

  if (!product) {
    notFound();
  }

  return (
    <div className="container mx-auto p-8">
      <a
        href="/products"
        className="text-blue-600 hover:underline mb-4 inline-block"
      >
        &larr; Back to products
      </a>
      <div className="flex gap-8 bg-white p-8 rounded-lg shadow">
        <img
          src={product.image}
          alt={product.title}
          className="w-96 h-96 object-contain"
        />
        <div>
          <h1 className="text-3xl font-bold">{product.title}</h1>
          <p className="text-2xl text-green-600 font-bold mt-4">
            ${product.price}
          </p>
          <p className="mt-6 text-gray-700 leading-relaxed">
            {product.description}
          </p>
          <button className="mt-6 px-8 py-3 bg-blue-600 text-white rounded-lg">
            Add to Cart
          </button>
        </div>
      </div>
    </div>
  );
}

// src/app/products/loading.tsx — ロードスケルトン画面
export default function ProductsLoading() {
  return (
    <div className="container mx-auto p-8">
      <div className="h-8 w-48 bg-gray-200 rounded animate-pulse mb-6" />
      <div className="grid grid-cols-4 gap-6">
        {Array.from({ length: 8 }).map((_, i) => (
          <div key={i} className="border rounded-lg p-4 bg-white">
            <div className="h-40 bg-gray-200 rounded animate-pulse" />
            <div className="h-4 bg-gray-200 rounded mt-2 animate-pulse" />
            <div className="h-4 w-16 bg-gray-200 rounded mt-2 animate-pulse" />
          </div>
        ))}
      </div>
    </div>
  );
}

// src/app/products/error.tsx — エラーバウンダリ
'use client';
export default function ProductsError({
  error,
  reset,
}: {
  error: Error;
  reset: () => void;
}) {
  return (
    <div className="container mx-auto p-8 text-center">
      <h2 className="text-2xl font-bold text-red-600">
        Failed to load products
      </h2>
      <button
        onClick={reset}
        className="mt-4 px-6 py-2 bg-blue-600 text-white rounded-lg"
      >
        Try Again
      </button>
    </div>
  );
}

期待される出力:

TEXT 📖 参照専用
ホーム (/):
  Welcome to ShopHub → [Browse Products]

商品一覧 (/products):
  ロード中... → 8 個のグレーのスケルトン画面
  ロード完了 → 商品カードグリッド (画像、タイトル、価格を含む)

商品詳細 (/products/1):
  商品画像 (左) + タイトル/価格/説明/カートに追加 (右)
  ← Back to products リンクで一覧に戻る

エラー状態:
  Failed to load products
  [Try Again] ボタン

404 状態:
  ルートの not-found.tsx から自動処理

❓ よくある質問

Q page.tsx に .jsx を .tsx の代わりに使えますか?
A はい。プロジェクトで TypeScript が有効になっていない場合、.jsx 拡張子を使用できます。ただし、本チュートリアルでは TypeScript を推奨しているため、すべてのコード例は .tsx を使用しています。
Q Next.js 16 で params が Promise なのはなぜですか?
A Next.js 15 から、paramssearchParams などの変数は非同期ページ生成をサポートするために Promise に変更されました。直接 params.id を使用すると TypeScript エラーが発生します。await params で値を取得する必要があります。
Q loading.tsx と Suspense の関係は?
A loading.tsx はページレベルの Suspense フォールバックです。Next.js は自動的に loading.tsx&lt;Suspense&gt; でラップします。コンポーネントレベルのローディング状態が必要な場合は、手動で &lt;Suspense fallback={...}&gt; を使用する必要があります。
Q error.tsxuse client が必要なのはなぜですか?
A error.tsx はクライアントコンポーネントである必要があります。インタラクション (リセットボタンなど) を処理する必要があるためです。サーバーコンポーネントにはイベントハンドラやフックを含めることができないため、'use client' ディレクティブが必要です。
Q route.tspage.tsx は共存できますか?
A いいえ。同じルートセグメント内では、page.tsxroute.ts は相互に排他的です。1つのディレクトリには1種類のルートハンドラのみを含めることができます。ページ (page.tsx) か API (route.ts) のいずれかです。
Q [...slug] と [[...catchAll]] の正確な違いは何ですか?
A [...slug] は少なくとも1つのパスセグメントが必要です (/docs/a は一致、/docs は一致せず 404 を返す)。[[...catchAll]] はゼロのパスセグメントを許可します (/categories と /categories/a の両方が一致)。「オプショナルカテゴリ閲覧」シナリオに適しています。

📖 まとめ


📝 練習問題

  1. 基礎問題 (⭐): プロジェクトに app/blog/[slug]/page.tsx というページを作成し、/blog/hello-world にアクセスしたときにコンソールに slug: hello-world と出力されるようにしてください。

  2. 発展問題 (⭐⭐): app/api/todos/route.ts を作成してシンプルな TODO API を実装し (GET は一覧を返し、POST は TODO を作成)、ブラウザでアクセスして確認してください。

  3. チャレンジ (⭐⭐⭐): app/docs/[...slug]/page.tsxapp/docs/[[...slug]]/page.tsx の2つの実装を作成し、/docs のアクセス動作をテストし、表を使って2つの違いを比較してください。

Web-Tutorial.com

Web-Tutorial 技術チーム

複数の開発者によって共同維持されているプログラミングチュートリアルプラットフォーム。各チュートリアルは専門分野の開発者が執筆・レビューしています。正確で信頼性の高いコンテンツを目指しています — 問題を見つけた場合はお知らせください。

100%