Next.js: App Router ファイルシステムルーティング
最終更新:2026-08-26
ファイルシステムルーティングは、図書館での本の分類方法のようなものです。ファイル名が URL パスを決定し、ディレクトリ構造がルーティングテーブルとして機能するため、手動設定は一切不要です。
1. 学ぶこと
- ファイルシステムルーティングのコア原則: ファイル名が URL になる
- 6つのファイル規約: page、layout、loading、error、not-found、route
- 動的ルーティング
[id]単一パラメータモード - キャッチオールルート
[...slug]とオプショナルキャッチオール[[...catchAll]] - Route Handlers API を使ったルートの定義と使用
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のルーティング設定はもう不要です。
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 になる
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 エンドポイント | 任意 | サーバーサイド実行 |
▶ サンプル: 最初のページを作成
Diagram: page.tsx; /; about/page.tsx; /about; products/page.tsx; /products.
// ============================================
// /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>
);
}
Content: About TaskFlow | TaskFlow is a collaborative project | 10K+ | Active Users
http://localhost:3000/about にアクセスすると表示:
About TaskFlow
TaskFlow is a collaborative project management platform...
10K+ Active Users | 50K+ Projects | 99.9% Uptime
4. 動的ルーティング
(1) 単一パラメータ動的ルーティング [id]
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' } |
▶ サンプル: 動的商品詳細ページ
Diagram: app/products; page.tsx → /products; [id; page.tsx → /products/1; /products/2.
// ============================================
// 商品詳細ページ — 動的ルーティング [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>
);
}
Server component fetches and renders data.
/products/1 にアクセスすると表示:
Fjallraven - Foldsack No. 1 Backpack
$109.95
Category: men's clothing
Your perfect pack for day trips and hikes...
(2) キャッチオールルート [...slug]
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'] |
▶ サンプル: キャッチオールドキュメントページ
Diagram: app/docs; [...slug; page.tsx; /docs/getting-started; /docs/guides/installation; /docs/api/authentication/overview.
// ============================================
// キャッチオールルーティング — 多段ドキュメントページ
// ファイル: 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>
);
}
DocsPage renders its UI.
/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]]
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'] |
▶ サンプル: オプショナルキャッチオールカテゴリページ
Diagram: app/categories; [[...catchAll; page.tsx; /categories; /categories/electronics; /categories/electronics/phones.
// ============================================
// オプショナルキャッチオール — カテゴリ閲覧ページ
// ファイル: 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>
);
}
Server-side data fetch renders list of items.
/categories にアクセスすると表示:
全カテゴリボタン (electronics, jewelery, men's clothing, women's clothing)
/categories/electronics にアクセスすると表示:
Category: electronics
electronics のみ表示 (8 商品カード)
5. loading.tsx と error.tsx
(1) loading.tsx — ロードスケルトン画面
graph LR
A[ユーザーがナビゲート] --> B[loading.tsx<br/>スケルトン表示]
B --> C[page.tsx<br/>データ準備完了]
C --> D[完全なページ]
style B fill:#f8d7da
style C fill:#d4edda
▶ サンプル: ロードスケルトン画面
Diagram: User Navigation; loading.tsx Wireframe Display; page.tsx Data Ready; Full Page.
// ============================================
// ロードスケルトン画面 — ページデータ取得中に表示
// ファイル: 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>
);
}
Renders a list of items using .map().
/products にアクセス中、データ読み込み完了まで表示:
8 個のグレーのプレースホルダーカード (パルスアニメーション付き)
データの読み込みが完了すると、プレースホルダーの内容が自動的に実際の内容に置き換わる
ちらつきなし、ホワイトスクリーンなし、スムーズな遷移
(2) error.tsx — エラーバウンダリ
▶ サンプル: エラーページ
The page renders as described above, with the UI updating based on the described behavior.
// ============================================
// エラーバウンダリ — 子コンポーネントのエラーをキャッチ
// ファイル: 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>
);
}
Content: Something went wrong! | Try Again
商品ページのデータ取得が失敗した場合、表示:
Something went wrong!
Failed to load products. Please try again.
[Try Again] ボタン → クリックで自動リトライ
(3) not-found.tsx — 404 ページ
6. Route Handlers (API ルート)
(1) route.ts の基本的な使い方
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 コマース商品閲覧システム
// ============================================
// 総合例: 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"
>
← 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>
);
}
期待される出力:
ホーム (/):
Welcome to ShopHub → [Browse Products]
商品一覧 (/products):
ロード中... → 8 個のグレーのスケルトン画面
ロード完了 → 商品カードグリッド (画像、タイトル、価格を含む)
商品詳細 (/products/1):
商品画像 (左) + タイトル/価格/説明/カートに追加 (右)
← Back to products リンクで一覧に戻る
エラー状態:
Failed to load products
[Try Again] ボタン
404 状態:
ルートの not-found.tsx から自動処理
❓ よくある質問
params が Promise なのはなぜですか?params、searchParams などの変数は非同期ページ生成をサポートするために Promise に変更されました。直接 params.id を使用すると TypeScript エラーが発生します。await params で値を取得する必要があります。loading.tsx と Suspense の関係は?loading.tsx はページレベルの Suspense フォールバックです。Next.js は自動的に loading.tsx を <Suspense> でラップします。コンポーネントレベルのローディング状態が必要な場合は、手動で <Suspense fallback={...}> を使用する必要があります。error.tsx に use client が必要なのはなぜですか?error.tsx はクライアントコンポーネントである必要があります。インタラクション (リセットボタンなど) を処理する必要があるためです。サーバーコンポーネントにはイベントハンドラやフックを含めることができないため、'use client' ディレクティブが必要です。route.ts と page.tsx は共存できますか?page.tsx と route.ts は相互に排他的です。1つのディレクトリには1種類のルートハンドラのみを含めることができます。ページ (page.tsx) か API (route.ts) のいずれかです。📖 まとめ
- ファイルシステムルーティング: ファイル名が URL パスとして機能し、ルーティングテーブルの手動設定が不要
page.tsxはページコンテンツを定義し、layout.tsxはレイアウトコンテナを定義します- 動的ルーティング:
[id]は単一パラメータに一致、[...slug]は多段パスに一致 [[...catchAll]]はゼロ引数を許可 (オプショナル)、[...slug]は少なくとも1つの引数が必要loading.tsxは自動的にSuspenseでラップされ、ロードスケルトン画面を表示error.tsxはクライアントコンポーネントで、エラーバウンダリとリトライボタンを提供not-found.tsxは 404 ページを処理し、notFound()関数で手動トリガーroute.tsは API エンドポイントを定義し、GET/POST/PUT/DELETE をサポート
📝 練習問題
-
基礎問題 (⭐): プロジェクトに
app/blog/[slug]/page.tsxというページを作成し、/blog/hello-worldにアクセスしたときにコンソールにslug: hello-worldと出力されるようにしてください。 -
発展問題 (⭐⭐):
app/api/todos/route.tsを作成してシンプルな TODO API を実装し (GET は一覧を返し、POST は TODO を作成)、ブラウザでアクセスして確認してください。 -
チャレンジ (⭐⭐⭐):
app/docs/[...slug]/page.tsxとapp/docs/[[...slug]]/page.tsxの2つの実装を作成し、/docsのアクセス動作をテストし、表を使って2つの違いを比較してください。