← 🏠 記事一覧に戻る
📅📁frontend🏷️

📝 Next.js App Routerの実践ガイド

App Routerの基本から応用まで、ファイルベースルーティングとServer Componentsの活用法を解説

Next.js App Routerの実践ガイド

Next.js 13で導入されたApp Routerは、従来のPages Routerから大きく進化しました。Server ComponentsやStreamingなど、最新の機能を活用した開発方法を解説します。

App Routerの基本構造

App Routerでは、appディレクトリ配下でファイルベースルーティングを行います。

app/
├── layout.tsx          # ルートレイアウト
├── page.tsx           # ホームページ
├── loading.tsx        # ローディングUI
├── error.tsx          # エラーハンドリング
├── not-found.tsx      # 404ページ
├── blog/
│   ├── layout.tsx     # ブログ専用レイアウト
│   ├── page.tsx       # ブログ一覧
│   └── [slug]/
│       └── page.tsx   # 個別記事ページ
└── api/
    └── posts/
        └── route.ts   # API Routes

Server Componentsの活用

App Routerの最大の特徴は、デフォルトでServer Componentsを使用することです。

データフェッチングの新しい方法

// app/blog/page.tsx
import { Suspense } from 'react';

interface Post {
  id: string;
  title: string;
  content: string;
  publishedAt: string;
}

// Server Componentでのデータフェッチング
async function getPosts(): Promise<Post[]> {
  const res = await fetch('https://api.example.com/posts', {
    next: { revalidate: 3600 } // ISR: 1時間ごとに再生成
  });
  
  if (!res.ok) {
    throw new Error('Failed to fetch posts');
  }
  
  return res.json();
}

export default async function BlogPage() {
  const posts = await getPosts();

  return (
    <div className="container mx-auto px-4">
      <h1 className="text-3xl font-bold mb-8">ブログ記事一覧</h1>
      <Suspense fallback={<PostsSkeleton />}>
        <PostsList posts={posts} />
      </Suspense>
    </div>
  );
}

function PostsList({ posts }: { posts: Post[] }) {
  return (
    <div className="grid gap-6">
      {posts.map((post) => (
        <article key={post.id} className="border rounded-lg p-6">
          <h2 className="text-xl font-semibold mb-2">{post.title}</h2>
          <p className="text-gray-600 mb-4">{post.content.slice(0, 200)}...</p>
          <time className="text-sm text-gray-500">
            {new Date(post.publishedAt).toLocaleDateString('ja-JP')}
          </time>
        </article>
      ))}
    </div>
  );
}

function PostsSkeleton() {
  return (
    <div className="grid gap-6">
      {Array.from({ length: 5 }).map((_, i) => (
        <div key={i} className="border rounded-lg p-6 animate-pulse">
          <div className="h-6 bg-gray-200 rounded mb-2"></div>
          <div className="h-4 bg-gray-200 rounded mb-4"></div>
          <div className="h-4 bg-gray-200 rounded w-1/3"></div>
        </div>
      ))}
    </div>
  );
}

レイアウトとテンプレート

ネストしたレイアウト

// app/layout.tsx (ルートレイアウト)
export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="ja">
      <body className="min-h-screen bg-white">
        <Header />
        <main>{children}</main>
        <Footer />
      </body>
    </html>
  );
}

// app/blog/layout.tsx (ブログ専用レイアウト)
export default function BlogLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <div className="flex">
      <aside className="w-64 bg-gray-100 p-4">
        <nav>
          <h2 className="font-bold mb-4">カテゴリー</h2>
          <ul className="space-y-2">
            <li><a href="/blog/frontend">フロントエンド</a></li>
            <li><a href="/blog/backend">バックエンド</a></li>
            <li><a href="/blog/design">デザイン</a></li>
          </ul>
        </nav>
      </aside>
      <div className="flex-1 p-8">
        {children}
      </div>
    </div>
  );
}

動的ルーティングとパラメータ

複数パラメータの取得

// app/blog/[category]/[slug]/page.tsx
interface PageProps {
  params: {
    category: string;
    slug: string;
  };
  searchParams: {
    [key: string]: string | string[] | undefined;
  };
}

export default async function BlogPost({ params, searchParams }: PageProps) {
  const { category, slug } = params;
  const post = await getPost(category, slug);

  if (!post) {
    notFound(); // 404ページに遷移
  }

  return (
    <article>
      <header className="mb-8">
        <h1 className="text-4xl font-bold mb-4">{post.title}</h1>
        <div className="flex items-center gap-4 text-gray-600">
          <span className="bg-blue-100 px-2 py-1 rounded text-sm">
            {category}
          </span>
          <time>{post.publishedAt}</time>
        </div>
      </header>
      <div dangerouslySetInnerHTML={{ __html: post.content }} />
    </article>
  );
}

// 静的ルート生成
export async function generateStaticParams() {
  const posts = await getAllPosts();
  
  return posts.map((post) => ({
    category: post.category,
    slug: post.slug,
  }));
}

// メタデータ生成
export async function generateMetadata({ params }: PageProps) {
  const post = await getPost(params.category, params.slug);
  
  return {
    title: post?.title,
    description: post?.description,
    openGraph: {
      title: post?.title,
      description: post?.description,
      images: [post?.coverImage],
    },
  };
}

API Routesの新しい書き方

// app/api/posts/route.ts
import { NextRequest, NextResponse } from 'next/server';

export async function GET(request: NextRequest) {
  const { searchParams } = new URL(request.url);
  const page = searchParams.get('page') || '1';
  const limit = searchParams.get('limit') || '10';

  try {
    const posts = await getPaginatedPosts(
      parseInt(page),
      parseInt(limit)
    );

    return NextResponse.json({
      posts,
      pagination: {
        page: parseInt(page),
        limit: parseInt(limit),
        total: posts.length,
      },
    });
  } catch (error) {
    return NextResponse.json(
      { error: 'Failed to fetch posts' },
      { status: 500 }
    );
  }
}

export async function POST(request: NextRequest) {
  try {
    const body = await request.json();
    const newPost = await createPost(body);
    
    return NextResponse.json(newPost, { status: 201 });
  } catch (error) {
    return NextResponse.json(
      { error: 'Failed to create post' },
      { status: 500 }
    );
  }
}

Streamingとローディング状態

// app/dashboard/page.tsx
import { Suspense } from 'react';

export default function Dashboard() {
  return (
    <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
      <Suspense fallback={<CardSkeleton />}>
        <StatsCard />
      </Suspense>
      
      <Suspense fallback={<CardSkeleton />}>
        <RecentActivity />
      </Suspense>
      
      <Suspense fallback={<CardSkeleton />}>
        <UserMetrics />
      </Suspense>
    </div>
  );
}

async function StatsCard() {
  const stats = await getStats(); // 非同期データフェッチ
  
  return (
    <div className="bg-white p-6 rounded-lg shadow">
      <h3 className="text-lg font-semibold mb-2">統計情報</h3>
      <p className="text-3xl font-bold text-blue-600">{stats.total}</p>
    </div>
  );
}

パフォーマンス最適化

画像最適化

import Image from 'next/image';

export default function OptimizedImage() {
  return (
    <Image
      src="/hero-image.jpg"
      alt="Hero image"
      width={1200}
      height={600}
      priority={true} // LCPの改善
      placeholder="blur"
      blurDataURL="data:image/jpeg;base64,..."
      className="rounded-lg"
    />
  );
}

フォント最適化

// app/layout.tsx
import { Inter, Noto_Sans_JP } from 'next/font/google';

const inter = Inter({
  subsets: ['latin'],
  display: 'swap',
});

const notoSansJP = Noto_Sans_JP({
  subsets: ['latin'],
  display: 'swap',
});

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="ja" className={`${inter.variable} ${notoSansJP.variable}`}>
      <body>{children}</body>
    </html>
  );
}

まとめ

App Routerの主な利点:

  1. Server Componentsによるパフォーマンス向上
  2. Streamingによる段階的なページロード
  3. ネストしたレイアウトによる効率的なUI構造
  4. 改善されたデータフェッチングAPI
  5. TypeScriptとの優れた統合

App Routerを使用することで、より高速で保守性の高いNext.jsアプリケーションを構築できます。段階的に移行することも可能なので、既存プロジェクトでも安心して導入できます。