技術解説

Next.js App Routerでの実装パターン

Next.js App Routerを使った実装パターンを、実際のコード例とともに詳しく解説します。

2400文字
Next.jsApp RouterReactサーバーコンポーネントWeb開発

Next.js App Routerでの実装パターン

Next.js App Routerを使った実装パターンを、実際のコード例とともに詳しく解説します。サーバーコンポーネントとクライアントコンポーネントの使い分けから、データフェッチング、ルーティングまで、実践的なパターンを紹介します。

Next.js App Routerとは

Next.js App Routerは、Next.js 13以降で導入された新しいルーティングシステムです。ファイルベースのルーティングに加えて、サーバーコンポーネント、ストリーミング、データフェッチングの最適化などの機能を提供します。

App Routerの主な特徴

  1. サーバーコンポーネント: デフォルトでサーバーサイドでレンダリング
  2. ファイルベースルーティング: appディレクトリ内のファイル構造でルーティング
  3. レイアウト: 共通レイアウトを簡単に実装
  4. ストリーミング: 段階的なデータ読み込みとレンダリング
  5. データフェッチング: サーバーコンポーネントでの効率的なデータ取得

サーバーコンポーネントとクライアントコンポーネント

サーバーコンポーネント

サーバーコンポーネントは、デフォルトでサーバーサイドでレンダリングされます。インタラクティブな機能(useState、useEffect、イベントハンドラーなど)は使用できませんが、データベースへの直接アクセスや、APIキーの使用などが可能です。

// app/tools/page.tsx(サーバーコンポーネント)
import { getAllToolMetadata } from '@/lib/tools/loadTools';

/**
 * ツール一覧ページ(サーバーコンポーネント)
 * データフェッチングをサーバーサイドで実行
 */
export default async function ToolsPage() {
  // サーバーサイドでデータを取得
  const tools = getAllToolMetadata();

  return (
    <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
      <h1 className="text-4xl font-bold mb-4">ツール一覧</h1>
      <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
        {tools.map((tool) => (
          <ToolCard key={tool.slug} tool={tool} />
        ))}
      </div>
    </div>
  );
}

クライアントコンポーネント

クライアントコンポーネントは、'use client'ディレクティブを使用して、クライアントサイドでレンダリングされます。インタラクティブな機能(useState、useEffect、イベントハンドラーなど)を使用できます。

// components/tools/ToolCard.tsx(クライアントコンポーネント)
'use client';

import { useState } from 'react';
import Link from 'next/link';
import { ToolMetadata } from '@/lib/tools/types';

interface ToolCardProps {
  tool: ToolMetadata;
}

/**
 * ツールカードコンポーネント(クライアントコンポーネント)
 * インタラクティブな機能を使用
 */
export function ToolCard({ tool }: ToolCardProps) {
  const [isHovered, setIsHovered] = useState(false);

  return (
    <Link
      href={`/tools/${tool.slug}`}
      className="block"
      onMouseEnter={() => setIsHovered(true)}
      onMouseLeave={() => setIsHovered(false)}
    >
      <div
        className={`p-6 bg-white rounded-lg shadow-md transition-all ${
          isHovered ? 'shadow-xl scale-105' : ''
        }`}
      >
        <div className="text-4xl mb-4">{tool.icon}</div>
        <h3 className="text-xl font-bold mb-2">{tool.name}</h3>
        <p className="text-gray-600">{tool.description}</p>
      </div>
    </Link>
  );
}

サーバーコンポーネントとクライアントコンポーネントの使い分け

サーバーコンポーネントを使用する場合:

  • データフェッチング
  • データベースへのアクセス
  • APIキーの使用
  • 大きな依存関係の使用(サーバーサイドのみで実行)

クライアントコンポーネントを使用する場合:

  • インタラクティブな機能(useState、useEffect)
  • イベントハンドラー(onClick、onChangeなど)
  • ブラウザ専用API(localStorage、windowなど)
  • サードパーティライブラリ(状態管理、アニメーションなど)

データフェッチングパターン

1. サーバーコンポーネントでのデータフェッチング

サーバーコンポーネントでは、async/awaitを使って直接データを取得できます。

// app/blog/[slug]/page.tsx
import { getArticle } from '@/lib/articles/loadArticles';
import { notFound } from 'next/navigation';

interface ArticlePageProps {
  params: {
    slug: string;
  };
}

/**
 * 記事詳細ページ(サーバーコンポーネント)
 * サーバーサイドでデータを取得
 */
export default async function ArticlePage({ params }: ArticlePageProps) {
  const article = getArticle(params.slug);

  if (!article) {
    notFound();
  }

  return (
    <article className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
      <h1 className="text-4xl font-bold mb-4">{article.title}</h1>
      <div className="prose prose-lg max-w-none">
        {/* 記事本文 */}
      </div>
    </article>
  );
}

2. 並列データフェッチング

複数のデータを並列で取得することで、パフォーマンスを向上させます。

// app/tools/[slug]/page.tsx
import { getToolMetadata } from '@/lib/tools/loadTools';
import { getRelatedArticles } from '@/lib/articles/loadArticles';

interface ToolPageProps {
  params: {
    slug: string;
  };
}

/**
 * ツール詳細ページ(サーバーコンポーネント)
 * 並列でデータを取得
 */
export default async function ToolPage({ params }: ToolPageProps) {
  // 並列でデータを取得
  const [tool, relatedArticles] = await Promise.all([
    getToolMetadata(params.slug),
    getRelatedArticles(params.slug, 3),
  ]);

  if (!tool) {
    notFound();
  }

  return (
    <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
      <h1 className="text-4xl font-bold mb-4">{tool.name}</h1>
      
      {/* ツールコンテンツ */}
      
      {/* 関連記事 */}
      {relatedArticles.length > 0 && (
        <section className="mt-12">
          <h2 className="text-2xl font-bold mb-6">関連記事</h2>
          {/* 関連記事の表示 */}
        </section>
      )}
    </div>
  );
}

3. ストリーミング

段階的なデータ読み込みとレンダリングを実装します。

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

/**
 * ダッシュボードページ(ストリーミング)
 */
export default function DashboardPage() {
  return (
    <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
      <h1 className="text-4xl font-bold mb-8">ダッシュボード</h1>
      
      {/* 高速に読み込めるコンテンツ */}
      <QuickStats />
      
      {/* 遅いデータはストリーミング */}
      <Suspense fallback={<StatsSkeleton />}>
        <SlowStats />
      </Suspense>
      
      <Suspense fallback={<ArticlesSkeleton />}>
        <RecentArticles />
      </Suspense>
    </div>
  );
}

/**
 * 遅いデータの取得(ストリーミング)
 */
async function SlowStats() {
  // 遅いデータフェッチング
  const stats = await fetchSlowStats();
  
  return <StatsDisplay stats={stats} />;
}

ルーティングパターン

1. 動的ルーティング

[slug][id]を使った動的ルーティングを実装します。

// app/blog/[slug]/page.tsx
interface ArticlePageProps {
  params: {
    slug: string;
  };
}

export default async function ArticlePage({ params }: ArticlePageProps) {
  const article = getArticle(params.slug);
  // ...
}

2. ネストされたルーティング

複数の動的セグメントを使ったネストされたルーティングを実装します。

// app/tools/[category]/[slug]/page.tsx
interface ToolPageProps {
  params: {
    category: string;
    slug: string;
  };
}

export default async function ToolPage({ params }: ToolPageProps) {
  const { category, slug } = params;
  // ...
}

3. ルートグループ

(group)を使ったルートグループで、URLに影響を与えずにレイアウトを整理します。

app/
  (marketing)/
    about/
    contact/
  (dashboard)/
    dashboard/
    settings/

レイアウトパターン

1. 共通レイアウト

app/layout.tsxで共通レイアウトを実装します。

// app/layout.tsx
import { Header } from '@/components/layout/Header';
import { Footer } from '@/components/layout/Footer';
import './globals.css';

export const metadata = {
  title: 'うつろい房',
  description: '物理シミュレーション、デザインツール、ユーティリティツールを提供',
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="ja">
      <body>
        <Header />
        <main>{children}</main>
        <Footer />
      </body>
    </html>
  );
}

2. ネストされたレイアウト

特定のルートにのみ適用されるレイアウトを実装します。

// app/tools/layout.tsx
export default function ToolsLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <div className="tools-layout">
      <aside>
        {/* ツール一覧のサイドバー */}
      </aside>
      <main>{children}</main>
    </div>
  );
}

メタデータとSEO

generateMetadata関数

動的なメタデータを生成します。

// app/blog/[slug]/page.tsx
import { generateMetadata } from 'next';

export async function generateMetadata({
  params,
}: {
  params: { slug: string };
}) {
  const article = getArticle(params.slug);

  if (!article) {
    return {
      title: '記事が見つかりません',
    };
  }

  return {
    title: `${article.title} | うつろい房`,
    description: article.description,
    openGraph: {
      title: article.title,
      description: article.description,
      type: 'article',
      publishedTime: article.publishedAt,
    },
  };
}

構造化データ(JSON-LD)

SEO対策として構造化データを追加します。

// app/blog/[slug]/page.tsx
export default async function ArticlePage({ params }: ArticlePageProps) {
  const article = getArticle(params.slug);

  const jsonLd = {
    '@context': 'https://schema.org',
    '@type': 'Article',
    headline: article.title,
    description: article.description,
    datePublished: article.publishedAt,
    dateModified: article.updatedAt,
    author: {
      '@type': 'Person',
      name: 'Tool Platform',
    },
  };

  return (
    <>
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
      />
      <article>
        {/* 記事コンテンツ */}
      </article>
    </>
  );
}

エラーハンドリング

エラーページ

error.tsxでエラーハンドリングを実装します。

// app/error.tsx
'use client';

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  return (
    <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
      <h2 className="text-2xl font-bold mb-4">エラーが発生しました</h2>
      <p className="text-gray-600 mb-4">{error.message}</p>
      <button
        onClick={() => reset()}
        className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
      >
        再試行
      </button>
    </div>
  );
}

404ページ

not-found.tsxで404ページを実装します。

// app/not-found.tsx
import Link from 'next/link';

export default function NotFound() {
  return (
    <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 text-center">
      <h1 className="text-4xl font-bold mb-4">404</h1>
      <p className="text-gray-600 mb-8">ページが見つかりませんでした</p>
      <Link
        href="/"
        className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
      >
        ホームに戻る
      </Link>
    </div>
  );
}

ローディング状態

ローディングページ

loading.tsxでローディング状態を表示します。

// app/tools/loading.tsx
export default function Loading() {
  return (
    <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
      <div className="animate-pulse">
        <div className="h-8 bg-gray-200 rounded w-1/4 mb-4"></div>
        <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
          {[...Array(6)].map((_, i) => (
            <div key={i} className="h-48 bg-gray-200 rounded"></div>
          ))}
        </div>
      </div>
    </div>
  );
}

API Routes

API Routeの実装

app/apiディレクトリでAPI Routeを実装します。

// app/api/contact/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { writeFileSync } from 'fs';
import { join } from 'path';

/**
 * お問い合わせフォームのAPI Route
 */
export async function POST(request: NextRequest) {
  try {
    const body = await request.json();
    const { name, email, subject, message } = body;

    // バリデーション
    if (!name || !email || !subject || !message) {
      return NextResponse.json(
        { error: 'すべての項目を入力してください' },
        { status: 400 }
      );
    }

    // ファイルに保存
    const data = {
      name,
      email,
      subject,
      message,
      timestamp: new Date().toISOString(),
    };

    const filename = `contact-${Date.now()}.json`;
    const filepath = join(process.cwd(), 'data', 'contacts', filename);
    writeFileSync(filepath, JSON.stringify(data, null, 2));

    return NextResponse.json({ success: true });
  } catch (error) {
    return NextResponse.json(
      { error: 'サーバーエラーが発生しました' },
      { status: 500 }
    );
  }
}

実践的な実装例

ツールプラットフォームの実装パターン

// app/tools/[slug]/page.tsx
import { notFound } from 'next/navigation';
import { getToolMetadata } from '@/lib/tools/loadTools';
import { getTool } from '@/lib/tools/toolRegistry';
import { getRelatedArticles } from '@/lib/articles/loadArticles';
import { ToolRunner } from '@/components/tools/ToolRunner';
import { RelatedArticles } from '@/components/blog/RelatedArticles';

interface ToolPageProps {
  params: {
    slug: string;
  };
}

/**
 * メタデータを生成
 */
export async function generateMetadata({ params }: ToolPageProps) {
  const tool = getToolMetadata(params.slug);

  if (!tool) {
    return {
      title: 'ツールが見つかりません',
    };
  }

  return {
    title: `${tool.name} - うつろい房`,
    description: tool.description,
  };
}

/**
 * ツール詳細ページ(サーバーコンポーネント)
 */
export default async function ToolPage({ params }: ToolPageProps) {
  // 並列でデータを取得
  const [tool, relatedArticles] = await Promise.all([
    getToolMetadata(params.slug),
    getRelatedArticles(params.slug, 3),
  ]);

  if (!tool) {
    notFound();
  }

  const registryEntry = getTool(params.slug);

  return (
    <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
      <h1 className="text-4xl font-bold mb-4">{tool.name}</h1>
      <p className="text-gray-600 text-lg mb-8">{tool.description}</p>

      {/* ツール実行エリア(クライアントコンポーネント) */}
      {registryEntry && (
        <ToolRunner tool={registryEntry} />
      )}

      {/* 関連記事(サーバーコンポーネント) */}
      {relatedArticles.length > 0 && (
        <RelatedArticles articles={relatedArticles} />
      )}
    </div>
  );
}

まとめ

Next.js App Routerを使うことで、以下のような実装パターンが可能になります:

  1. サーバーコンポーネントとクライアントコンポーネントの使い分け: パフォーマンスとインタラクティブ性のバランス
  2. 効率的なデータフェッチング: サーバーサイドでのデータ取得、並列フェッチング、ストリーミング
  3. 柔軟なルーティング: 動的ルーティング、ネストされたルーティング、ルートグループ
  4. 共通レイアウト: ルートレイアウト、ネストされたレイアウト
  5. SEO対策: メタデータ生成、構造化データ
  6. エラーハンドリング: エラーページ、404ページ、ローディング状態
  7. API Routes: サーバーサイドAPIの実装

これらのパターンを組み合わせることで、高性能で保守性の高いNext.jsアプリケーションを実装できます。

関連記事

関連記事