このチュートリアルで学ぶこと
✓ App Routerの基本構造
✓ Server Components と Client Components
✓ データフェッチング
✓ ルーティングとレイアウト
✓ ローディングとエラーハンドリング
✓ Server Actions
前提条件
- Reactの基本知識
- TypeScriptの基本知識
- Node.js 18以上がインストールされていること
プロジェクトのセットアップ
# プロジェクト作成
npx create-next-app@latest my-app --typescript --tailwind --eslint --app
cd my-app
npm run dev
ディレクトリ構造
my-app/
├── app/
│ ├── layout.tsx # ルートレイアウト
│ ├── page.tsx # ホームページ
│ ├── globals.css # グローバルスタイル
│ └── ...
├── components/ # 共有コンポーネント
├── lib/ # ユーティリティ関数
└── public/ # 静的ファイル
Step 1: App Routerの基本
ファイルベースルーティング
app/
├── page.tsx # / (ホーム)
├── about/
│ └── page.tsx # /about
├── blog/
│ ├── page.tsx # /blog
│ └── [slug]/
│ └── page.tsx # /blog/:slug
├── products/
│ ├── page.tsx # /products
│ └── [...categories]/
│ └── page.tsx # /products/* (キャッチオール)
└── (marketing)/
├── pricing/
│ └── page.tsx # /pricing
└── contact/
└── page.tsx # /contact
基本的なページコンポーネント
// app/page.tsx
export default function HomePage() {
return (
<main className="container mx-auto p-4">
<h1 className="text-4xl font-bold">ホーム</h1>
<p className="mt-4">Next.js App Routerへようこそ!</p>
</main>
);
}
動的ルーティング
// app/blog/[slug]/page.tsx
interface Props {
params: Promise<{ slug: string }>;
}
export default async function BlogPost({ params }: Props) {
const { slug } = await params;
return (
<article>
<h1>ブログ記事: {slug}</h1>
</article>
);
}
// 静的パラメータの生成
export async function generateStaticParams() {
const posts = await fetch('https://api.example.com/posts').then(res => res.json());
return posts.map((post: { slug: string }) => ({
slug: post.slug,
}));
}
Step 2: レイアウトとテンプレート
ルートレイアウト
// 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: 'Next.js App Router Demo',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="ja">
<body className={inter.className}>
<header className="bg-gray-800 text-white p-4">
<nav className="container mx-auto flex gap-4">
<a href="/">ホーム</a>
<a href="/blog">ブログ</a>
<a href="/about">About</a>
</nav>
</header>
{children}
<footer className="bg-gray-100 p-4 mt-8">
<p className="text-center">© 2024 My App</p>
</footer>
</body>
</html>
);
}
ネストされたレイアウト
// app/blog/layout.tsx
export default function BlogLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="container mx-auto flex gap-8 p-4">
<aside className="w-64">
<h2 className="font-bold mb-4">カテゴリ</h2>
<ul className="space-y-2">
<li><a href="/blog?category=tech">テクノロジー</a></li>
<li><a href="/blog?category=design">デザイン</a></li>
<li><a href="/blog?category=business">ビジネス</a></li>
</ul>
</aside>
<main className="flex-1">{children}</main>
</div>
);
}
ルートグループ
// app/(auth)/layout.tsx
// (auth)はURLに影響しない
export default function AuthLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-100">
<div className="bg-white p-8 rounded-lg shadow-md w-96">
{children}
</div>
</div>
);
}
// app/(auth)/login/page.tsx → /login
// app/(auth)/register/page.tsx → /register
Step 3: Server Components と Client Components
Server Components(デフォルト)
// app/products/page.tsx
// これはServer Component(デフォルト)
async function getProducts() {
const res = await fetch('https://api.example.com/products', {
cache: 'force-cache', // 静的生成
});
return res.json();
}
export default async function ProductsPage() {
const products = await getProducts();
return (
<div className="grid grid-cols-3 gap-4">
{products.map((product: { id: number; name: string; price: number }) => (
<div key={product.id} className="border p-4 rounded">
<h2>{product.name}</h2>
<p>¥{product.price.toLocaleString()}</p>
</div>
))}
</div>
);
}
Client Components
// components/Counter.tsx
'use client'; // Client Componentとして指定
import { useState } from 'react';
export default function Counter() {
const [count, setCount] = useState(0);
return (
<div className="flex items-center gap-4">
<button
onClick={() => setCount(count - 1)}
className="px-4 py-2 bg-gray-200 rounded"
>
-
</button>
<span className="text-2xl">{count}</span>
<button
onClick={() => setCount(count + 1)}
className="px-4 py-2 bg-blue-500 text-white rounded"
>
+
</button>
</div>
);
}
使い分けのパターン
// app/dashboard/page.tsx (Server Component)
import Counter from '@/components/Counter';
import UserProfile from '@/components/UserProfile';
async function getUserData() {
const res = await fetch('https://api.example.com/user');
return res.json();
}
export default async function DashboardPage() {
// サーバーサイドでデータ取得
const user = await getUserData();
return (
<div>
{/* Server Componentでデータを渡す */}
<UserProfile user={user} />
{/* Client Componentでインタラクティブ機能 */}
<Counter />
</div>
);
}
// components/UserProfile.tsx (Server Component)
interface User {
name: string;
email: string;
avatar: string;
}
export default function UserProfile({ user }: { user: User }) {
return (
<div className="flex items-center gap-4">
<img src={user.avatar} alt={user.name} className="w-12 h-12 rounded-full" />
<div>
<p className="font-bold">{user.name}</p>
<p className="text-gray-600">{user.email}</p>
</div>
</div>
);
}
Step 4: データフェッチング
並列データフェッチング
// app/dashboard/page.tsx
async function getUser() {
const res = await fetch('https://api.example.com/user');
return res.json();
}
async function getNotifications() {
const res = await fetch('https://api.example.com/notifications');
return res.json();
}
async function getStats() {
const res = await fetch('https://api.example.com/stats');
return res.json();
}
export default async function DashboardPage() {
// 並列でデータ取得
const [user, notifications, stats] = await Promise.all([
getUser(),
getNotifications(),
getStats(),
]);
return (
<div>
<h1>ようこそ、{user.name}さん</h1>
<p>通知: {notifications.length}件</p>
<p>今月の売上: ¥{stats.revenue.toLocaleString()}</p>
</div>
);
}
キャッシュ制御
// 静的データ(ビルド時に取得)
const staticData = await fetch('https://api.example.com/data', {
cache: 'force-cache',
});
// 動的データ(毎回取得)
const dynamicData = await fetch('https://api.example.com/data', {
cache: 'no-store',
});
// 時間ベースの再検証
const revalidatedData = await fetch('https://api.example.com/data', {
next: { revalidate: 3600 }, // 1時間
});
// タグベースの再検証
const taggedData = await fetch('https://api.example.com/products', {
next: { tags: ['products'] },
});
// revalidateTag('products') で再検証
Suspenseを使った段階的レンダリング
// app/page.tsx
import { Suspense } from 'react';
import RecommendedProducts from '@/components/RecommendedProducts';
import LatestNews from '@/components/LatestNews';
export default function HomePage() {
return (
<div>
<h1>ホーム</h1>
<Suspense fallback={<div>おすすめ商品を読み込み中...</div>}>
<RecommendedProducts />
</Suspense>
<Suspense fallback={<div>ニュースを読み込み中...</div>}>
<LatestNews />
</Suspense>
</div>
);
}
Step 5: ローディングとエラーハンドリング
loading.tsx
// app/blog/loading.tsx
export default function Loading() {
return (
<div className="space-y-4">
{[...Array(3)].map((_, i) => (
<div key={i} className="animate-pulse">
<div className="h-6 bg-gray-200 rounded w-3/4 mb-2"></div>
<div className="h-4 bg-gray-200 rounded w-1/2"></div>
</div>
))}
</div>
);
}
error.tsx
// app/blog/error.tsx
'use client';
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div className="text-center py-8">
<h2 className="text-2xl font-bold text-red-600 mb-4">
エラーが発生しました
</h2>
<p className="text-gray-600 mb-4">{error.message}</p>
<button
onClick={reset}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
>
再試行
</button>
</div>
);
}
not-found.tsx
// app/blog/[slug]/not-found.tsx
import Link from 'next/link';
export default function NotFound() {
return (
<div className="text-center py-8">
<h2 className="text-2xl font-bold mb-4">記事が見つかりません</h2>
<p className="text-gray-600 mb-4">
お探しの記事は存在しないか、削除された可能性があります。
</p>
<Link
href="/blog"
className="text-blue-500 hover:underline"
>
ブログ一覧に戻る
</Link>
</div>
);
}
// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation';
export default async function BlogPost({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const post = await getPost(slug);
if (!post) {
notFound(); // not-found.tsxを表示
}
return <article>{/* ... */}</article>;
}
Step 6: Server Actions
基本的なServer Action
// app/contact/page.tsx
async function submitContact(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 db.contact.create({
data: { name, email, message },
});
// メール送信など
}
export default function ContactPage() {
return (
<form action={submitContact} className="space-y-4">
<div>
<label htmlFor="name">名前</label>
<input
id="name"
name="name"
required
className="w-full border p-2 rounded"
/>
</div>
<div>
<label htmlFor="email">メール</label>
<input
id="email"
name="email"
type="email"
required
className="w-full border p-2 rounded"
/>
</div>
<div>
<label htmlFor="message">メッセージ</label>
<textarea
id="message"
name="message"
required
className="w-full border p-2 rounded"
/>
</div>
<button
type="submit"
className="px-4 py-2 bg-blue-500 text-white rounded"
>
送信
</button>
</form>
);
}
useFormStateとuseFormStatus
// app/actions.ts
'use server';
import { revalidatePath } from 'next/cache';
export async function createPost(prevState: any, formData: FormData) {
const title = formData.get('title') as string;
const content = formData.get('content') as string;
if (!title || title.length < 3) {
return { error: 'タイトルは3文字以上必要です' };
}
await db.post.create({
data: { title, content },
});
revalidatePath('/posts');
return { success: true };
}
// app/posts/new/page.tsx
'use client';
import { useActionState } from 'react';
import { useFormStatus } from 'react-dom';
import { createPost } from '@/app/actions';
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button
type="submit"
disabled={pending}
className="px-4 py-2 bg-blue-500 text-white rounded disabled:opacity-50"
>
{pending ? '送信中...' : '投稿'}
</button>
);
}
export default function NewPostPage() {
const [state, formAction] = useActionState(createPost, null);
return (
<form action={formAction} className="space-y-4">
{state?.error && (
<p className="text-red-500">{state.error}</p>
)}
<div>
<label htmlFor="title">タイトル</label>
<input
id="title"
name="title"
className="w-full border p-2 rounded"
/>
</div>
<div>
<label htmlFor="content">内容</label>
<textarea
id="content"
name="content"
className="w-full border p-2 rounded"
/>
</div>
<SubmitButton />
</form>
);
}
実践課題: ブログアプリ
// lib/db.ts
export interface Post {
id: string;
title: string;
content: string;
createdAt: Date;
}
// 簡易的なインメモリDB
let posts: Post[] = [];
export const db = {
posts: {
findMany: () => posts,
findUnique: (id: string) => posts.find(p => p.id === id),
create: (data: Omit<Post, 'id' | 'createdAt'>) => {
const post = {
...data,
id: crypto.randomUUID(),
createdAt: new Date(),
};
posts.push(post);
return post;
},
delete: (id: string) => {
posts = posts.filter(p => p.id !== id);
},
},
};
// app/posts/page.tsx
import Link from 'next/link';
import { db } from '@/lib/db';
export default function PostsPage() {
const posts = db.posts.findMany();
return (
<div className="container mx-auto p-4">
<div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-bold">ブログ</h1>
<Link
href="/posts/new"
className="px-4 py-2 bg-blue-500 text-white rounded"
>
新規作成
</Link>
</div>
<div className="space-y-4">
{posts.map(post => (
<article key={post.id} className="border p-4 rounded">
<h2 className="text-xl font-bold">
<Link href={`/posts/${post.id}`} className="hover:underline">
{post.title}
</Link>
</h2>
<p className="text-gray-600 mt-2">
{post.content.substring(0, 100)}...
</p>
</article>
))}
</div>
</div>
);
}
ベストプラクティス
1. コンポーネントの使い分け
- データ取得 → Server Component
- インタラクティブ機能 → Client Component
- 'use client'は必要な場所のみ
2. データフェッチング
- 可能な限り並列で取得
- 適切なキャッシュ戦略を選択
- Suspenseで段階的レンダリング
3. パフォーマンス
- 動的インポートで分割
- 画像はnext/imageを使用
- フォントはnext/fontを使用
4. エラーハンドリング
- error.tsxで適切に処理
- not-found.tsxで404ページ
- loading.tsxでローディング状態
まとめ
Next.js App Routerは、Server ComponentsとClient Componentsを組み合わせた新しいアーキテクチャです。適切に使い分けることで、パフォーマンスと開発体験の両方を向上させることができます。
← 一覧に戻る