Next.js App Routerは、Next.js 13で導入されたReact Server Componentsベースの新しいルーティングシステムです。従来のPages Routerと比べて、レイアウトのネスト、ストリーミング、Server Actionsなど、多くの新機能が利用可能になりました。
本記事では、App Routerの基本的なディレクトリ構成、レイアウトシステム、データ取得、Server Actions、そしてミドルウェアまで、実務で必要な知識を網羅的に解説します。
App Routerの基本概念
App Routerは、appディレクトリ内のフォルダ構造がそのままURLパスに対応するファイルベースルーティングを採用しています。
プロジェクトセットアップ
# 新規プロジェクト作成
npx create-next-app@latest my-app --typescript --tailwind --eslint --app --src-dir
# または pnpm
pnpm create next-app my-app --typescript --tailwind --eslint --app --src-dir
特殊ファイルの役割
App Routerでは、ファイル名に特別な意味があります。
app/
├── layout.tsx # ルートレイアウト(必須)
├── page.tsx # / のページ
├── loading.tsx # ローディングUI(Suspense)
├── error.tsx # エラーUI(Error Boundary)
├── not-found.tsx # 404ページ
├── global-error.tsx # グローバルエラーハンドラー
├── about/
│ └── page.tsx # /about のページ
├── blog/
│ ├── page.tsx # /blog のページ
│ ├── [slug]/
│ │ └── page.tsx # /blog/:slug の動的ページ
│ └── layout.tsx # /blog 配下共通レイアウト
├── api/
│ └── users/
│ └── route.ts # /api/users のAPIエンドポイント
└── (marketing)/
├── pricing/
│ └── page.tsx # /pricing(グループ化、URLに影響しない)
└── features/
└── page.tsx # /features
page.tsx:そのルートのメインコンテンツ。このファイルがあるディレクトリがURLとして公開されます。
layout.tsx:そのルート以下で共有されるレイアウト。ページ遷移時に再レンダリングされません。
loading.tsx:ページの読み込み中に表示されるUI。React Suspenseのフォールバックとして機能します。
error.tsx:エラー発生時に表示されるUI。React Error Boundaryとして機能します。
レイアウトシステム
App Routerのレイアウトは、ネスト可能な共有UIを定義する仕組みです。ページ遷移時にレイアウトの状態が保持され、不要な再レンダリングが発生しません。
ルートレイアウト
// src/app/layout.tsx(ルートレイアウト - 必須)
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: {
default: "My App",
template: "%s | My App", // 子ページのタイトルが入る
},
description: "My awesome application",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="ja">
<body className={inter.className}>
<Header />
<main>{children}</main>
<Footer />
</body>
</html>
);
}
ネストレイアウト
// src/app/dashboard/layout.tsx
import { redirect } from "next/navigation";
import { getSession } from "@/lib/auth";
export default async function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
const session = await getSession();
// 未認証ユーザーはログインページへリダイレクト
if (!session) {
redirect("/login");
}
return (
<div className="flex">
<DashboardSidebar user={session.user} />
<div className="flex-1 p-6">{children}</div>
</div>
);
}
ルートグループ
括弧()で囲んだフォルダ名は、URLに影響しないグルーピングに使えます。
app/
├── (auth)/
│ ├── layout.tsx # 認証ページ共通レイアウト(シンプルなレイアウト)
│ ├── login/
│ │ └── page.tsx # /login
│ └── register/
│ └── page.tsx # /register
├── (dashboard)/
│ ├── layout.tsx # ダッシュボード共通レイアウト(サイドバー付き)
│ ├── overview/
│ │ └── page.tsx # /overview
│ └── settings/
│ └── page.tsx # /settings
└── (marketing)/
├── layout.tsx # マーケティングページ共通レイアウト
├── page.tsx # /(トップページ)
└── about/
└── page.tsx # /about
ページとルーティング
静的ページ
// src/app/about/page.tsx
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "会社概要",
description: "当社の概要と沿革をご紹介します。",
};
export default function AboutPage() {
return (
<div>
<h1>会社概要</h1>
<p>ここに会社の説明が入ります。</p>
</div>
);
}
動的ルーティング
// src/app/blog/[slug]/page.tsx
import { notFound } from "next/navigation";
import type { Metadata } from "next";
interface Props {
params: Promise<{ slug: string }>;
}
// 動的メタデータ
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const article = await fetchArticle(slug);
if (!article) return { title: "記事が見つかりません" };
return {
title: article.title,
description: article.excerpt,
openGraph: {
title: article.title,
description: article.excerpt,
images: [article.thumbnail],
},
};
}
// 静的パスの生成(SSG)
export async function generateStaticParams() {
const articles = await fetchAllArticleSlugs();
return articles.map((article) => ({
slug: article.slug,
}));
}
export default async function ArticlePage({ params }: Props) {
const { slug } = await params;
const article = await fetchArticle(slug);
if (!article) {
notFound(); // not-found.tsx を表示
}
return (
<article>
<h1>{article.title}</h1>
<time>{new Date(article.publishedAt).toLocaleDateString("ja-JP")}</time>
<div dangerouslySetInnerHTML={{ __html: article.content }} />
</article>
);
}
キャッチオールルート
// src/app/docs/[...slug]/page.tsx
// /docs/getting-started, /docs/api/users, /docs/api/users/create など
interface Props {
params: Promise<{ slug: string[] }>;
}
export default async function DocsPage({ params }: Props) {
const { slug } = await params;
// slug = ["getting-started"] or ["api", "users"] or ["api", "users", "create"]
const path = slug.join("/");
const doc = await fetchDoc(path);
return (
<div>
<h1>{doc.title}</h1>
<div>{doc.content}</div>
</div>
);
}
データ取得とキャッシュ
Server Componentでのデータ取得
// src/app/products/page.tsx
interface SearchParams {
category?: string;
page?: string;
sort?: string;
}
export default async function ProductsPage({
searchParams,
}: {
searchParams: Promise<SearchParams>;
}) {
const params = await searchParams;
const page = parseInt(params.page || "1", 10);
const category = params.category;
const sort = params.sort || "newest";
// サーバーサイドでデータ取得
const { products, total, totalPages } = await fetchProducts({
page,
category,
sort,
perPage: 20,
});
return (
<div>
<h1>商品一覧</h1>
<p>全{total}件</p>
<ProductFilters currentCategory={category} currentSort={sort} />
<div className="grid grid-cols-3 gap-4">
{products.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
<Pagination currentPage={page} totalPages={totalPages} />
</div>
);
}
loading.tsxとerror.tsx
// src/app/products/loading.tsx
export default function ProductsLoading() {
return (
<div>
<h1>商品一覧</h1>
<div className="grid grid-cols-3 gap-4">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="animate-pulse bg-gray-200 h-64 rounded" />
))}
</div>
</div>
);
}
// src/app/products/error.tsx
"use client"; // Error Boundaryはクライアントコンポーネントである必要がある
export default function ProductsError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div>
<h2>エラーが発生しました</h2>
<p>{error.message}</p>
<button onClick={reset}>再試行</button>
</div>
);
}
キャッシュ戦略の使い分け
// 1. 静的生成(デフォルト)
// ビルド時にHTMLを生成、CDNにキャッシュ
async function StaticPage() {
const data = await fetch("https://api.example.com/data");
return <div>{/* ... */}</div>;
}
// 2. ISR(Incremental Static Regeneration)
// 指定時間ごとにバックグラウンドで再生成
async function ISRPage() {
const data = await fetch("https://api.example.com/data", {
next: { revalidate: 3600 }, // 1時間ごと
});
return <div>{/* ... */}</div>;
}
// 3. 動的レンダリング
// リクエストごとにサーバーで実行
import { cookies, headers } from "next/headers";
async function DynamicPage() {
const cookieStore = await cookies(); // cookies()を使うと自動的に動的になる
const token = cookieStore.get("auth-token");
return <div>{/* ... */}</div>;
}
// セグメント単位での設定
export const dynamic = "force-dynamic"; // 常に動的
// export const dynamic = "force-static"; // 常に静的
// export const revalidate = 3600; // ISR(1時間)
Server Actions
Server Actionsは、フォーム送信やデータ変更をサーバー側で処理するための仕組みです。APIエンドポイントを別途作成する必要がありません。
フォーム処理の実装
// src/app/articles/new/actions.ts
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
const ArticleSchema = z.object({
title: z.string().min(1, "タイトルは必須です").max(200),
content: z.string().min(1, "本文は必須です"),
category: z.string().min(1, "カテゴリを選択してください"),
tags: z.string().transform((val) => val.split(",").map((t) => t.trim()).filter(Boolean)),
status: z.enum(["draft", "published"]),
});
export type ArticleFormState = {
success: boolean;
errors: Record<string, string[]>;
message?: string;
};
export async function createArticle(
prevState: ArticleFormState,
formData: FormData
): Promise<ArticleFormState> {
const rawData = {
title: formData.get("title"),
content: formData.get("content"),
category: formData.get("category"),
tags: formData.get("tags"),
status: formData.get("status"),
};
const result = ArticleSchema.safeParse(rawData);
if (!result.success) {
return {
success: false,
errors: result.error.flatten().fieldErrors as Record<string, string[]>,
};
}
try {
const article = await db.article.create({
data: {
...result.data,
authorId: await getCurrentUserId(),
},
});
revalidatePath("/articles");
redirect(`/articles/${article.slug}`);
} catch (error) {
return {
success: false,
errors: {},
message: "記事の作成に失敗しました",
};
}
}
// src/app/articles/new/page.tsx
import { ArticleForm } from "./ArticleForm";
export default function NewArticlePage() {
return (
<div>
<h1>記事作成</h1>
<ArticleForm />
</div>
);
}
// src/app/articles/new/ArticleForm.tsx
"use client";
import { useActionState } from "react";
import { createArticle, type ArticleFormState } from "./actions";
const initialState: ArticleFormState = {
success: false,
errors: {},
};
export function ArticleForm() {
const [state, formAction, isPending] = useActionState(createArticle, initialState);
return (
<form action={formAction}>
{state.message && <div className="error">{state.message}</div>}
<div>
<label htmlFor="title">タイトル</label>
<input id="title" name="title" type="text" required />
{state.errors.title?.map((e) => <span key={e} className="error">{e}</span>)}
</div>
<div>
<label htmlFor="content">本文</label>
<textarea id="content" name="content" rows={10} required />
{state.errors.content?.map((e) => <span key={e} className="error">{e}</span>)}
</div>
<div>
<label htmlFor="category">カテゴリ</label>
<select id="category" name="category">
<option value="">選択してください</option>
<option value="tech">技術</option>
<option value="business">ビジネス</option>
</select>
</div>
<div>
<label htmlFor="tags">タグ(カンマ区切り)</label>
<input id="tags" name="tags" type="text" placeholder="React, Next.js, TypeScript" />
</div>
<div>
<label>
<input type="radio" name="status" value="draft" defaultChecked /> 下書き
</label>
<label>
<input type="radio" name="status" value="published" /> 公開
</label>
</div>
<button type="submit" disabled={isPending}>
{isPending ? "作成中..." : "記事を作成"}
</button>
</form>
);
}
API RoutesとMiddleware
Route Handlers(API Routes)
// src/app/api/users/route.ts
import { NextRequest, NextResponse } from "next/server";
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const page = parseInt(searchParams.get("page") || "1", 10);
const perPage = parseInt(searchParams.get("perPage") || "20", 10);
const users = await db.user.findMany({
skip: (page - 1) * perPage,
take: perPage,
});
const total = await db.user.count();
return NextResponse.json({
data: users,
pagination: { page, perPage, total, totalPages: Math.ceil(total / perPage) },
});
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const user = await db.user.create({ data: body });
return NextResponse.json({ data: user }, { status: 201 });
} catch (error) {
return NextResponse.json(
{ error: "ユーザーの作成に失敗しました" },
{ status: 500 }
);
}
}
// src/app/api/users/[id]/route.ts
import { NextRequest, NextResponse } from "next/server";
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const user = await db.user.findUnique({ where: { id } });
if (!user) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
return NextResponse.json({ data: user });
}
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const body = await request.json();
const user = await db.user.update({ where: { id }, data: body });
return NextResponse.json({ data: user });
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
await db.user.delete({ where: { id } });
return new NextResponse(null, { status: 204 });
}
Middleware
// src/middleware.ts(プロジェクトルートに配置)
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// 認証チェック
const token = request.cookies.get("auth-token")?.value;
// ダッシュボード配下は認証必須
if (pathname.startsWith("/dashboard") && !token) {
const loginUrl = new URL("/login", request.url);
loginUrl.searchParams.set("redirect", pathname);
return NextResponse.redirect(loginUrl);
}
// 認証済みユーザーがログインページにアクセスしたらダッシュボードへ
if ((pathname === "/login" || pathname === "/register") && token) {
return NextResponse.redirect(new URL("/dashboard", request.url));
}
// レスポンスヘッダーの追加
const response = NextResponse.next();
response.headers.set("X-Request-Id", crypto.randomUUID());
return response;
}
// Middlewareを適用するパスの設定
export const config = {
matcher: [
// 静的ファイル・API以外に適用
"/((?!_next/static|_next/image|favicon.ico|api).*)",
],
};
実務でのディレクトリ構成ベストプラクティス
src/
├── app/ # App Router
│ ├── (auth)/ # 認証関連ページ
│ │ ├── layout.tsx
│ │ ├── login/
│ │ └── register/
│ ├── (dashboard)/ # ダッシュボード
│ │ ├── layout.tsx
│ │ ├── overview/
│ │ └── settings/
│ ├── (marketing)/ # 公開ページ
│ │ ├── layout.tsx
│ │ ├── page.tsx # トップページ
│ │ ├── about/
│ │ └── blog/
│ ├── api/ # API Routes
│ ├── layout.tsx # ルートレイアウト
│ └── not-found.tsx # 404
├── components/ # 共有コンポーネント
│ ├── ui/ # UIプリミティブ(Button, Input等)
│ ├── layout/ # Header, Footer, Sidebar等
│ └── features/ # 機能単位のコンポーネント
├── lib/ # ユーティリティ・設定
│ ├── db.ts # DB接続
│ ├── auth.ts # 認証ヘルパー
│ └── utils.ts # 汎用ユーティリティ
├── types/ # 型定義
└── middleware.ts # Middleware
ポイントは、appディレクトリにはルーティングに関するファイルだけを配置し、再利用可能なコンポーネントやユーティリティはcomponentsやlibに分離することです。
まとめ
Next.js App Routerは、React Server Componentsを基盤とした新しいWeb開発のパラダイムです。
ファイルベースルーティング:appディレクトリのフォルダ構造がURLに対応します。page.tsx、layout.tsx、loading.tsx、error.tsxなどの特殊ファイルが自動的に適切な役割を果たします。
レイアウト:ネスト可能なレイアウトにより、共有UIの定義が容易です。ルートグループ()でURLに影響しないグルーピングも可能です。
データ取得:Server Componentsでasync/awaitによる直接的なデータ取得が可能です。キャッシュ戦略(静的生成、ISR、動的レンダリング)をfetchオプションやセグメント設定で制御します。
Server Actions:フォーム処理やデータ変更をサーバー側で型安全に処理できます。APIエンドポイントの作成が不要になるケースが多くなります。
Middleware:認証チェック、リダイレクト、ヘッダー操作などをEdge Runtimeで高速に処理します。
App Routerは学ぶべき概念が多いですが、Server Components、レイアウト、Server Actionsの3つを押さえれば実務で十分に活用できます。まずは小さなプロジェクトで試し、段階的に高度な機能を取り入れていきましょう。
関連記事
AIエージェント開発入門|自律型AIの仕組みと構築方法を解説【2026年版】
AI駆動コーディングワークフロー|Claude Code・Cursor・Copilotの実践的使い分け
プロンプトエンジニアリング上級編|Chain-of-Thought・Few-Shot・ReActの実践
APIレート制限の設計と実装|トークンバケット・スライディングウィンドウ解説
APIバージョニング戦略|URL・ヘッダー・クエリパラメータの使い分け
BIツール入門|Metabase・Redash・Looker Studioでデータ可視化する方法
チャットボット開発入門|LINE Bot・Slack Botの構築方法と活用事例
CI/CDパイプラインの基礎|継続的インテグレーション・デリバリーの全体像