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

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を組み合わせた新しいアーキテクチャです。適切に使い分けることで、パフォーマンスと開発体験の両方を向上させることができます。

← 一覧に戻る