Next.js App Router実践ガイド

intermediate | 70分 で読める | 2024.12.17

公式ドキュメント

このチュートリアルで学ぶこと

  • ✓ 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

ポイント: App Routerでは全てのコンポーネントがデフォルトでServer Componentです。クライアント操作が必要な場合のみ'use client'を付けます。

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}
 <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

実践メモ: 'use server'を関数の先頭に宣言すると、フォーム送信やボタンクリックからサーバーサイド処理を直接呼び出せます。

注意: 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’は必要な場所のみ
  1. データフェッチング
  • 可能な限り並列で取得
  • 適切なキャッシュ戦略を選択
  • Suspenseで段階的レンダリング
  1. パフォーマンス
  • 動的インポートで分割
  • 画像はnext/imageを使用
  • フォントはnext/fontを使用
  1. エラーハンドリング
  • error.tsxで適切に処理
  • not-found.tsxで404ページ
  • loading.tsxでローディング状態

まとめ

Next.js App Routerは、Server ComponentsとClient Componentsを組み合わせた新しいアーキテクチャです。適切に使い分けることで、パフォーマンスと開発体験の両方を向上させることができます。

参考リソース

← 一覧に戻る
PR
PR
PR
PR