Web Development

Next.js 15 App Router完全ガイド - React 19 & Turbopack対応

kitahara-dev2025/11/0315 min read
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

paramssearchParamsが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では、paramssearchParamsが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アプリケーションを構築してください!

🔗 参考リンク

#Next.js#React 19#Turbopack#TypeScript

著者について

kitahara-devによって執筆されました。