11
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

はじめに

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アプリケーションを構築しましょう!

11
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?