📝 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の主な利点:
- Server Componentsによるパフォーマンス向上
- Streamingによる段階的なページロード
- ネストしたレイアウトによる効率的なUI構造
- 改善されたデータフェッチングAPI
- TypeScriptとの優れた統合
App Routerを使用することで、より高速で保守性の高いNext.jsアプリケーションを構築できます。段階的に移行することも可能なので、既存プロジェクトでも安心して導入できます。