はじめに
Next.js 13で導入されたApp Routerは、Reactアプリケーション開発に革命をもたらしました。Server Components、Streaming、そして新しいファイルベースルーティングにより、パフォーマンスと開発体験が大幅に向上しています。
この記事では、App Routerの基礎から実践的な使い方まで、詳しく解説します。
このテーマでつまずきやすいのは
- Server ComponentsとClient Componentsの境界が曖昧なまま実装してしまう
- fetchキャッシュと再検証の挙動を理解せずに 更新されない 問題を踏む
- Server ActionsやRoute Handlersを 何に使うべきか の判断基準がない
のような 実務的な判断の部分です。
本記事は単なる機能紹介に寄らず
なぜApp Routerが必要なのか いつ何を使い分けるのか まで含めて整理します。
この記事で扱う前提とゴール
前提
- Next.jsの基本的な使い方は知っている
- Reactの状態管理やコンポーネント設計の基礎は分かる
ゴール
- RSCのメリットと制約を理解し どこをClientにするか迷わない
- 取得データが更新されない理由をキャッシュ戦略から説明できる
- Pages Routerからの移行で ハマりどころ を回避できる
なぜApp Routerなのか Pages Routerと何が違うのか
App Routerの本質は
- React Server Componentsを前提にし サーバーでできることを増やす
- StreamingとSuspenseで 画面を早く見せる
- レイアウトやローディング エラー境界 をルーティングに統合する
の三点です。
単にディレクトリ構造が変わったわけではなく
レンダリングとデータ取得の責務分担が変わります。
App Routerに移行すると
- 初期表示が速くなりやすい
- 秘密情報をクライアントに出さずにデータ取得できる
- ルート単位でキャッシュや再検証を設計できる
一方で
- ブラウザでしか動かない処理はClientに閉じる必要がある
- クライアントJSを増やすとメリットが薄れる
というトレードオフもあります。
まず押さえる設計判断の軸
迷ったら 次の順で判断すると手戻りが減ります。
その処理はサーバーで完結できるか
- DBや外部APIから読むだけ ならServer Componentで完結させる
- クリックや入力で画面が変わる ならClient Componentが必要
そのデータはどれくらいの頻度で変わるか
- ほぼ変わらない 静的に近い force-cache
- たまに変わる revalidate
- 毎回変わる no-store
境界をどこに置くか
Client Componentは 境界より下が全部クライアントバンドルに入ります。
つまり
- 必要最小限の部品だけをClientにする
- 可能なら親はServerのまま 子だけClientにする
が基本戦略です。
App Routerの基本概念
ディレクトリ構造
app/
├── layout.tsx # ルートレイアウト
├── page.tsx # ホームページ(/)
├── loading.tsx # ローディングUI
├── error.tsx # エラーUI
├── not-found.tsx # 404ページ
├── globals.css # グローバルスタイル
│
├── about/
│ └── page.tsx # /about
│
├── blog/
│ ├── page.tsx # /blog
│ ├── [slug]/
│ │ └── page.tsx # /blog/:slug
│ └── [...slug]/
│ └── page.tsx # /blog/*(キャッチオール)
│
├── dashboard/
│ ├── layout.tsx # ダッシュボード共通レイアウト
│ ├── page.tsx # /dashboard
│ ├── settings/
│ │ └── page.tsx # /dashboard/settings
│ └── (overview)/ # ルートグループ(URLに影響なし)
│ ├── stats/
│ │ └── page.tsx
│ └── analytics/
│ └── page.tsx
│
└── api/
└── users/
└── route.ts # API Route(/api/users)
予約ファイル名
| ファイル名 | 役割 |
|---|---|
page.tsx |
ルートのUI |
layout.tsx |
共有レイアウト |
template.tsx |
再レンダリングされるレイアウト |
loading.tsx |
ローディングUI |
error.tsx |
エラーUI |
not-found.tsx |
404 UI |
route.ts |
API エンドポイント |
middleware.ts |
ミドルウェア |
Server ComponentsとClient Components
Server Components(デフォルト)
App Routerでは、すべてのコンポーネントがデフォルトでServer Componentsです。
// app/users/page.tsx
// これはServer Component
async function getUsers() {
const res = await fetch('https://api.example.com/users', {
cache: 'no-store' // 動的データ取得
});
return res.json();
}
export default async function UsersPage() {
// サーバーサイドで直接データを取得
const users = await getUsers();
return (
<div>
<h1>ユーザー一覧</h1>
<ul>
{users.map((user: any) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
</div>
);
}
Server Componentsのメリット
- データベースに直接アクセス可能
- 機密情報(APIキーなど)をクライアントに露出しない
- バンドルサイズの削減
- SEOに有利
Client Components
インタラクティブな機能が必要な場合はClient Componentsを使用します。
// app/components/Counter.tsx
'use client';
import { useState } from 'react';
export function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>カウント: {count}</p>
<button onClick={() => setCount(count + 1)}>
増やす
</button>
</div>
);
}
// app/components/SearchForm.tsx
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
export function SearchForm() {
const [query, setQuery] = useState('');
const router = useRouter();
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
router.push(`/search?q=${encodeURIComponent(query)}`);
};
return (
<form onSubmit={handleSubmit}>
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="検索..."
/>
<button type="submit">検索</button>
</form>
);
}
使い分けの指針
| 機能 | Server Component | Client Component |
|---|---|---|
| データ取得 | ✅ | ❌(useEffectが必要) |
| useState/useEffect | ❌ | ✅ |
| イベントハンドラ | ❌ | ✅ |
| ブラウザAPI | ❌ | ✅ |
| カスタムフック | ❌ | ✅ |
| React Context | ❌ | ✅ |
データ取得
Server Componentsでのデータ取得
// app/posts/page.tsx
// 静的データ(ビルド時に取得、キャッシュ)
async function getStaticPosts() {
const res = await fetch('https://api.example.com/posts', {
cache: 'force-cache' // デフォルト
});
return res.json();
}
// 動的データ(毎回取得)
async function getDynamicPosts() {
const res = await fetch('https://api.example.com/posts', {
cache: 'no-store'
});
return res.json();
}
// 時間ベースの再検証
async function getRevalidatedPosts() {
const res = await fetch('https://api.example.com/posts', {
next: { revalidate: 3600 } // 1時間ごとに再検証
});
return res.json();
}
// タグベースの再検証
async function getTaggedPosts() {
const res = await fetch('https://api.example.com/posts', {
next: { tags: ['posts'] }
});
return res.json();
}
並列データ取得
// app/dashboard/page.tsx
async function getUser() {
const res = await fetch('https://api.example.com/user');
return res.json();
}
async function getPosts() {
const res = await fetch('https://api.example.com/posts');
return res.json();
}
async function getAnalytics() {
const res = await fetch('https://api.example.com/analytics');
return res.json();
}
export default async function Dashboard() {
// 並列でデータ取得(ウォーターフォールを防ぐ)
const [user, posts, analytics] = await Promise.all([
getUser(),
getPosts(),
getAnalytics()
]);
return (
<div>
<UserProfile user={user} />
<PostsList posts={posts} />
<AnalyticsChart data={analytics} />
</div>
);
}
データベースへの直接アクセス
// app/users/[id]/page.tsx
import { prisma } from '@/lib/prisma';
export default async function UserPage({
params
}: {
params: { id: string }
}) {
// サーバーサイドでデータベースに直接アクセス
const user = await prisma.user.findUnique({
where: { id: params.id },
include: { posts: true }
});
if (!user) {
notFound();
}
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
<h2>投稿一覧</h2>
<ul>
{user.posts.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
</div>
);
}
レイアウトとテンプレート
ルートレイアウト
// 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: {
template: '%s | My App',
default: 'My App'
},
description: 'My awesome Next.js application',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="ja">
<body className={inter.className}>
<header>
<nav>ナビゲーション</nav>
</header>
<main>{children}</main>
<footer>フッター</footer>
</body>
</html>
);
}
ネストされたレイアウト
// app/dashboard/layout.tsx
import { Sidebar } from '@/components/Sidebar';
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="dashboard-container">
<Sidebar />
<div className="dashboard-content">
{children}
</div>
</div>
);
}
動的メタデータ
// app/posts/[slug]/page.tsx
import { Metadata } from 'next';
type Props = {
params: { slug: string };
};
// 動的にメタデータを生成
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const post = await getPost(params.slug);
return {
title: post.title,
description: post.excerpt,
openGraph: {
title: post.title,
description: post.excerpt,
images: [post.coverImage],
},
};
}
export default async function PostPage({ params }: Props) {
const post = await getPost(params.slug);
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
);
}
ローディングとエラー処理
ローディングUI
// app/posts/loading.tsx
export default function Loading() {
return (
<div className="loading-container">
<div className="skeleton">
<div className="skeleton-title" />
<div className="skeleton-text" />
<div className="skeleton-text" />
</div>
</div>
);
}
Suspenseを使った部分的なローディング
// app/dashboard/page.tsx
import { Suspense } from 'react';
import { UserProfile } from './UserProfile';
import { RecentPosts } from './RecentPosts';
import { Analytics } from './Analytics';
export default function Dashboard() {
return (
<div>
<Suspense fallback={<div>ユーザー情報を読み込み中...</div>}>
<UserProfile />
</Suspense>
<div className="grid grid-cols-2 gap-4">
<Suspense fallback={<div>投稿を読み込み中...</div>}>
<RecentPosts />
</Suspense>
<Suspense fallback={<div>分析データを読み込み中...</div>}>
<Analytics />
</Suspense>
</div>
</div>
);
}
エラー処理
// app/posts/error.tsx
'use client';
import { useEffect } from 'react';
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
// エラーをログに記録
console.error(error);
}, [error]);
return (
<div className="error-container">
<h2>エラーが発生しました</h2>
<p>{error.message}</p>
<button onClick={() => reset()}>
もう一度試す
</button>
</div>
);
}
Not Found
// app/posts/[slug]/page.tsx
import { notFound } from 'next/navigation';
export default async function PostPage({ params }: Props) {
const post = await getPost(params.slug);
if (!post) {
notFound();
}
return <article>{/* ... */}</article>;
}
// app/posts/not-found.tsx
import Link from 'next/link';
export default function NotFound() {
return (
<div>
<h2>記事が見つかりませんでした</h2>
<p>お探しの記事は存在しないか、削除された可能性があります。</p>
<Link href="/posts">
記事一覧に戻る
</Link>
</div>
);
}
Server Actions
Server Actionsを使うと、フォーム送信をシンプルに実装できます。
基本的な使い方
// app/contact/page.tsx
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
async function submitForm(formData: FormData) {
'use server';
const name = formData.get('name') as string;
const email = formData.get('email') as string;
const message = formData.get('message') as string;
// データベースに保存
await prisma.contact.create({
data: { name, email, message }
});
// キャッシュを更新
revalidatePath('/contact');
// リダイレクト
redirect('/contact/thanks');
}
export default function ContactPage() {
return (
<form action={submitForm}>
<input type="text" name="name" required />
<input type="email" name="email" required />
<textarea name="message" required />
<button type="submit">送信</button>
</form>
);
}
バリデーション付きのフォーム
// app/actions/createPost.ts
'use server';
import { z } from 'zod';
import { revalidatePath } from 'next/cache';
const PostSchema = z.object({
title: z.string().min(1, 'タイトルは必須です').max(100),
content: z.string().min(10, '本文は10文字以上必要です'),
});
export type State = {
errors?: {
title?: string[];
content?: string[];
};
message?: string;
};
export async function createPost(prevState: State, formData: FormData) {
const validatedFields = PostSchema.safeParse({
title: formData.get('title'),
content: formData.get('content'),
});
if (!validatedFields.success) {
return {
errors: validatedFields.error.flatten().fieldErrors,
message: '入力内容に問題があります',
};
}
const { title, content } = validatedFields.data;
try {
await prisma.post.create({
data: { title, content },
});
} catch (error) {
return {
message: 'データベースエラーが発生しました',
};
}
revalidatePath('/posts');
redirect('/posts');
}
// app/posts/new/page.tsx
'use client';
import { useFormState, useFormStatus } from 'react-dom';
import { createPost, State } from '@/app/actions/createPost';
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? '送信中...' : '投稿する'}
</button>
);
}
export default function NewPostPage() {
const initialState: State = { message: '', errors: {} };
const [state, dispatch] = useFormState(createPost, initialState);
return (
<form action={dispatch}>
<div>
<label htmlFor="title">タイトル</label>
<input type="text" id="title" name="title" />
{state.errors?.title && (
<p className="error">{state.errors.title[0]}</p>
)}
</div>
<div>
<label htmlFor="content">本文</label>
<textarea id="content" name="content" />
{state.errors?.content && (
<p className="error">{state.errors.content[0]}</p>
)}
</div>
{state.message && (
<p className="error">{state.message}</p>
)}
<SubmitButton />
</form>
);
}
Route Handlers(API Routes)
基本的なAPI
// app/api/users/route.ts
import { NextRequest, NextResponse } from 'next/server';
export async function GET() {
const users = await prisma.user.findMany();
return NextResponse.json(users);
}
export async function POST(request: NextRequest) {
const body = await request.json();
const user = await prisma.user.create({
data: body
});
return NextResponse.json(user, { status: 201 });
}
動的ルート
// app/api/users/[id]/route.ts
import { NextRequest, NextResponse } from 'next/server';
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
const user = await prisma.user.findUnique({
where: { id: params.id }
});
if (!user) {
return NextResponse.json(
{ error: 'User not found' },
{ status: 404 }
);
}
return NextResponse.json(user);
}
export async function PUT(
request: NextRequest,
{ params }: { params: { id: string } }
) {
const body = await request.json();
const user = await prisma.user.update({
where: { id: params.id },
data: body
});
return NextResponse.json(user);
}
export async function DELETE(
request: NextRequest,
{ params }: { params: { id: string } }
) {
await prisma.user.delete({
where: { id: params.id }
});
return new NextResponse(null, { status: 204 });
}
ミドルウェア
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
// 認証チェック
const token = request.cookies.get('auth-token');
if (!token && request.nextUrl.pathname.startsWith('/dashboard')) {
return NextResponse.redirect(new URL('/login', request.url));
}
// ヘッダーの追加
const response = NextResponse.next();
response.headers.set('x-custom-header', 'value');
return response;
}
export const config = {
matcher: ['/dashboard/:path*', '/api/:path*']
};
静的生成とISR
静的パスの生成
// app/posts/[slug]/page.tsx
// ビルド時に静的ページを生成
export async function generateStaticParams() {
const posts = await getPosts();
return posts.map((post) => ({
slug: post.slug,
}));
}
export default async function PostPage({ params }: Props) {
const post = await getPost(params.slug);
return <article>{/* ... */}</article>;
}
ISR(Incremental Static Regeneration)
// app/posts/[slug]/page.tsx
// ページレベルで再検証間隔を設定
export const revalidate = 3600; // 1時間
export default async function PostPage({ params }: Props) {
const post = await getPost(params.slug);
return <article>{/* ... */}</article>;
}
オンデマンド再検証
// app/api/revalidate/route.ts
import { revalidatePath, revalidateTag } from 'next/cache';
import { NextRequest, NextResponse } from 'next/server';
export async function POST(request: NextRequest) {
const { path, tag, secret } = await request.json();
// シークレットの確認
if (secret !== process.env.REVALIDATION_SECRET) {
return NextResponse.json({ error: 'Invalid secret' }, { status: 401 });
}
if (path) {
revalidatePath(path);
}
if (tag) {
revalidateTag(tag);
}
return NextResponse.json({ revalidated: true });
}
Pages Routerからの移行
主な変更点
| Pages Router | App Router |
|---|---|
pages/ |
app/ |
getServerSideProps |
Server Component + fetch
|
getStaticProps |
Server Component + fetch(cache) |
getStaticPaths |
generateStaticParams |
_app.tsx |
layout.tsx |
_document.tsx |
layout.tsx(html, body) |
useRouter (next/router) |
useRouter (next/navigation) |
API Routes (pages/api/) |
Route Handlers (app/api/route.ts) |
移行例
// Before: pages/posts/[id].tsx
export async function getServerSideProps({ params }) {
const post = await getPost(params.id);
return { props: { post } };
}
export default function PostPage({ post }) {
return <article>{post.title}</article>;
}
// After: app/posts/[id]/page.tsx
export default async function PostPage({ params }) {
const post = await getPost(params.id);
return <article>{post.title}</article>;
}
// Before: pages/posts/[id].tsx with getStaticPaths
export async function getStaticPaths() {
const posts = await getAllPosts();
return {
paths: posts.map(post => ({ params: { id: post.id } })),
fallback: 'blocking'
};
}
// After: app/posts/[id]/page.tsx
export async function generateStaticParams() {
const posts = await getAllPosts();
return posts.map(post => ({ id: post.id }));
}
まとめ
App Routerの主要なポイントをまとめます。
| 機能 | 説明 |
|---|---|
| Server Components | デフォルトでサーバーサイドレンダリング |
| Client Components |
'use client'でインタラクティブ機能 |
| データ取得 |
async/awaitで直接fetchまたはDB |
| レイアウト |
layout.tsxで共有UI |
| ローディング |
loading.tsxとSuspense |
| エラー処理 |
error.tsxでエラーバウンダリ |
| Server Actions | フォーム処理をシンプルに |
| Route Handlers | API エンドポイント |
App Routerを使いこなして、モダンなNext.jsアプリケーションを構築しましょう!