Next.js 15 App Router完全ガイド - React 19 & Turbopack対応
Next.js 15 App Router完全ガイド
Next.js 15は、React 19のサポート、Turbopackの安定化、async Request APIなど、画期的な機能を多数導入しました。このガイドでは、実践的な実装パターンとともに、最新のApp Routerを徹底解説します。
📦 Next.js 15の主な新機能
⚡ React 19完全サポート
React 19の新機能を完全サポート:
- Server Actions: フォーム送信やデータ更新を簡潔に記述
- useFormStatus: フォームの送信状態を管理
- useOptimistic: 楽観的更新でUXを向上
🚀 Turbopackの安定化
- 開発サーバーの起動が最大76.7%高速化
- HMR(Hot Module Replacement)のパフォーマンス向上
- メモリ使用量の最適化
🔄 async Request API
paramsとsearchParamsがPromiseベースに変更され、より柔軟なデータフェッチが可能になりました。
📁 ディレクトリ構造の全体像
App Routerでは、特別なファイル名が予約されています:
src/app/
layout.tsx # ルートレイアウト(必須)
page.tsx # トップページ
loading.tsx # ローディングUI
error.tsx # エラーハンドリング
not-found.tsx # 404ページ
blog/
layout.tsx # ブログセクション共通レイアウト
page.tsx # /blog
loading.tsx # ブログページのローディング
[slug]/
page.tsx # /blog/[slug] - 動的ルート
not-found.tsx # 記事が見つからない時
🎯 ページコンポーネントの基本
// src/app/blog/page.tsx
// ファイル名: page.tsx でルートが作成される
export default function BlogPage() {
return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold">ブログ</h1>
<p className="mt-4 text-gray-600">最新記事一覧</p>
</div>
);
}
🔀 動的ルーティング(Next.js 15対応)
async Request APIの新しい構文
Next.js 15では、paramsとsearchParamsがPromiseになりました。
// src/app/blog/[slug]/page.tsx
// 📌 重要: paramsはPromise<{}>型に変更されました
interface Props {
params: Promise<{ slug: string }>;
searchParams: Promise<{ page?: string }>;
}
export default async function BlogDetail({ params, searchParams }: Props) {
// awaitして値を取得
const { slug } = await params;
const { page = '1' } = await searchParams;
// データフェッチ(try-catchでエラーハンドリング)
const post = await fetch(`https://api.example.com/posts/${slug}`)
.then(res => {
if (!res.ok) throw new Error('Post not found');
return res.json();
});
return (
<article className="prose lg:prose-xl mx-auto p-8">
<h1 className="text-4xl font-bold mb-4">{post.title}</h1>
<div className="text-gray-600 mb-8">
{post.author} • {post.publishedAt}
</div>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
);
}
generateStaticParams(SEO重要!)
動的ルートを静的生成する場合はgenerateStaticParamsを使用します。
// src/app/blog/[slug]/page.tsx
// ビルド時に生成するパスを指定
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,
}));
}
// 上記で指定されていないパスは404になる
export const dynamicParams = false;
🎨 レイアウトとローディングUI
layout.tsx - 共通レイアウト
// src/app/blog/layout.tsx
// レイアウトは子ページ間で共有され、再レンダリングされません
export default function BlogLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="min-h-screen bg-gray-50">
{/* ヘッダー */}
<nav className="bg-white border-b sticky top-0 z-10">
<div className="container mx-auto px-4 py-4">
<h2 className="text-xl font-bold">ブログ</h2>
</div>
</nav>
{/* メインコンテンツ */}
<div className="container mx-auto px-4 py-8 flex gap-8">
<aside className="w-64 flex-shrink-0">
<h3 className="font-semibold mb-4">カテゴリ</h3>
<ul className="space-y-2">
<li>
<a href="/blog/tech" className="hover:text-blue-600">
技術
</a>
</li>
<li>
<a href="/blog/design" className="hover:text-blue-600">
デザイン
</a>
</li>
</ul>
</aside>
<main className="flex-1">{children}</main>
</div>
</div>
);
}
loading.tsx - ローディングUI(UX重要!)
// src/app/blog/loading.tsx
// ページ読み込み中に自動的に表示されます
export default function Loading() {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="flex flex-col items-center gap-4">
<div className="w-12 h-12 border-4 border-blue-600 border-t-transparent rounded-full animate-spin" />
<p className="text-gray-600">読み込み中...</p>
</div>
</div>
);
}
⚠️ エラーハンドリング
error.tsx - エラーページ
// src/app/blog/error.tsx
// エラーが発生すると自動的に表示されます
'use client'; // エラーバウンダリはクライアントコンポーネント
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div className="flex flex-col items-center justify-center min-h-[400px] p-8">
<h2 className="text-2xl font-bold mb-4">エラーが発生しました</h2>
<p className="text-gray-600 mb-6">{error.message}</p>
<button
onClick={() => reset()}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
>
再試行
</button>
</div>
);
}
not-found.tsx - 404ページ
// src/app/blog/[slug]/not-found.tsx
import Link from 'next/link';
export default function NotFound() {
return (
<div className="flex flex-col items-center justify-center min-h-[400px] p-8">
<h2 className="text-2xl font-bold mb-4">記事が見つかりません</h2>
<p className="text-gray-600 mb-6">
お探しのブログ記事は存在しないか、削除された可能性があります。
</p>
<Link
href="/blog"
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
>
ブログ一覧に戻る
</Link>
</div>
);
}
📊 データフェッチ戦略
Static Rendering(デフォルト)
// ビルド時にデータを取得(キャッシュされる)
export default async function Page() {
const data = await fetch('https://api.example.com/data', {
cache: 'force-cache' // デフォルト値
}).then(res => res.json());
return <div>{data.title}</div>;
}
Dynamic Rendering
// リクエストごとに最新データを取得
export default async function Page() {
const data = await fetch('https://api.example.com/data', {
cache: 'no-store' // キャッシュしない
}).then(res => res.json());
return <div>{data.title}</div>;
}
Incremental Static Regeneration(ISR)
// 10秒ごとに再検証して更新
export default async function Page() {
const data = await fetch('https://api.example.com/data', {
next: { revalidate: 10 } // 10秒ごと
}).then(res => res.json());
return <div>{data.title}</div>;
}
🎬 Server Actions(React 19の目玉機能!)
Server Actionsを使うと、API Routesなしでフォーム送信やデータ更新が可能です。
基本的なServer Action
// src/app/actions.ts
// Server Actionsは別ファイルに分けるのが推奨
'use server';
export async function createPost(formData: FormData) {
const title = formData.get('title') as string;
const content = formData.get('content') as string;
// データベースへ保存
const post = await db.post.create({
data: { title, content },
});
// revalidatePathで再検証
revalidatePath('/blog');
return { success: true, post };
}
フォームでの使用
// src/app/blog/new/page.tsx
import { createPost } from '@/app/actions';
export default function NewPostPage() {
return (
<form action={createPost} className="max-w-2xl mx-auto p-8">
<div className="mb-4">
<label htmlFor="title" className="block font-semibold mb-2">
タイトル
</label>
<input
type="text"
id="title"
name="title"
required
className="w-full border rounded px-3 py-2"
/>
</div>
<div className="mb-6">
<label htmlFor="content" className="block font-semibold mb-2">
本文
</label>
<textarea
id="content"
name="content"
required
rows={10}
className="w-full border rounded px-3 py-2"
/>
</div>
<button
type="submit"
className="px-6 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
>
投稿する
</button>
</form>
);
}
useFormStatus で送信状態を管理
'use client';
import { useFormStatus } from 'react-dom';
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button
type="submit"
disabled={pending}
className="px-6 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400"
>
{pending ? '送信中...' : '投稿する'}
</button>
);
}
🔄 サーバーコンポーネント vs クライアントコンポーネント
サーバーコンポーネント(デフォルト)
// src/app/blog/page.tsx
// デフォルトでサーバーコンポーネント
export default async function BlogPage() {
// サーバーでデータフェッチ(クライアントに送信されない)
const posts = await fetch('https://api.example.com/posts').then(res => res.json());
return (
<div>
{posts.map(post => (
<article key={post.id}>
<h2>{post.title}</h2>
<p>{post.excerpt}</p>
</article>
))}
</div>
);
}
クライアントコンポーネント
// src/components/Counter.tsx
'use client'; // この指定が必要
import { useState } from 'react';
export default function Counter() {
const [count, setCount] = useState(0);
return (
<button
onClick={() => setCount(count + 1)}
className="px-4 py-2 bg-blue-600 text-white rounded"
>
Count: {count}
</button>
);
}
⚡ Streaming & Suspense
Suspenseを使うことで、ページの一部が読み込み中でも残りを表示できます。
// src/app/dashboard/page.tsx
import { Suspense } from 'react';
// 遅いコンポーネント
async function Analytics() {
const data = await fetch('https://api.example.com/analytics', {
cache: 'no-store'
}).then(res => res.json());
return <div>アナリティクス: {data.views}</div>;
}
export default function DashboardPage() {
return (
<div className="p-8">
<h1 className="text-3xl font-bold mb-6">ダッシュボード</h1>
{/* すぐに表示される */}
<div className="mb-6">
<h2>ようこそ!</h2>
</div>
{/* 遅延読み込み */}
<Suspense fallback={<div className="animate-pulse">読み込み中...</div>}>
<Analytics />
</Suspense>
</div>
);
}
🎯 メタデータとSEO
// src/app/blog/[slug]/page.tsx
import type { Metadata } from 'next';
// 動的メタデータの生成
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const post = await fetch(`https://api.example.com/posts/${slug}`).then(res => res.json());
return {
title: post.title,
description: post.excerpt,
openGraph: {
title: post.title,
description: post.excerpt,
images: [post.coverImage],
},
twitter: {
card: 'summary_large_image',
title: post.title,
description: post.excerpt,
images: [post.coverImage],
},
};
}
export default async function BlogPost({ params }: Props) {
// ... ページコンテンツ
}
🚀 パフォーマンスのベストプラクティス
1. 画像の最適化
import Image from 'next/image';
export default function HeroSection() {
return (
<Image
src="/hero.jpg"
alt="Hero image"
width={1200}
height={600}
priority // LCPに影響する画像
placeholder="blur" // ぼかしプレースホルダー
blurDataURL="data:image/..." // Base64エンコードされた画像
/>
);
}
2. 動的インポート(コード分割)
import dynamic from 'next/dynamic';
// クライアントサイドのみで読み込む重いコンポーネント
const Chart = dynamic(() => import('@/components/Chart'), {
loading: () => <div>グラフ読み込み中...</div>,
ssr: false, // サーバーサイドレンダリングを無効化
});
export default function Dashboard() {
return (
<div>
<h1>ダッシュボード</h1>
<Chart data={data} />
</div>
);
}
3. Route Handlers(API Routes)
// src/app/api/posts/route.ts
import { NextResponse } from 'next/server';
// GET /api/posts
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const page = searchParams.get('page') || '1';
const posts = await db.post.findMany({
skip: (Number(page) - 1) * 10,
take: 10,
});
return NextResponse.json(posts);
}
// POST /api/posts
export async function POST(request: Request) {
const body = await request.json();
const post = await db.post.create({
data: body,
});
return NextResponse.json(post, { status: 201 });
}
⚠️ よくある落とし穴と解決策
1. "use client"の誤用
// ❌ 悪い例:全体をクライアントコンポーネントにしている
'use client';
export default async function Page() { // エラー!
const data = await fetch('...');
}
// ✅ 良い例:インタラクティブな部分だけクライアントコンポーネント
// page.tsx(サーバーコンポーネント)
export default async function Page() {
const data = await fetch('...');
return <InteractiveButton data={data} />;
}
// InteractiveButton.tsx(クライアントコンポーネント)
'use client';
export function InteractiveButton({ data }) {
const [count, setCount] = useState(0);
// ...
}
2. paramsのawait忘れ
// ❌ Next.js 15ではエラー
export default async function Page({ params }) {
const { id } = params; // エラー!paramsはPromise
}
// ✅ 正しい書き方
export default async function Page({ params }) {
const { id } = await params; // awaitが必要
}
3. generateStaticParamsの設定忘れ
// SEO重要!動的ルートは必ず設定する
export async function generateStaticParams() {
const posts = await getPosts();
return posts.map(post => ({ slug: post.slug }));
}
📝 まとめ
Next.js 15のApp Routerは、以下の機能により最強のフレームワークに進化しました:
- ✅ React 19完全サポート - Server Actions、useFormStatusなど
- ✅ Turbopackの安定化 - 開発速度が最大76.7%向上
- ✅ async Request API - より柔軟なデータフェッチ
- ✅ 強力なエラーハンドリング - error.tsx、not-found.tsx
- ✅ SEO最適化 - generateStaticParams、メタデータAPI
- ✅ 優れたUX - Streaming、Suspense、loading.tsx
この記事で紹介したパターンを活用し、高速でSEOに強く、保守性の高いWebアプリケーションを構築してください!
🔗 参考リンク
著者について
kitahara-devによって執筆されました。