Next.js notFoundの実装パターン:業務で使える404エラーハンドリング徹底解説

React / Next.js

Next.js notFoundの実装パターン:業務で使える404エラーハンドリング徹底解説

1. notFound()とは?簡易的な解説

Next.js 13以降で導入されたnotFound()は、アプリケーション内で404エラーページを表示するための組み込み関数です。従来のHTTPステータスコードやメタデータの手動設定とは異なり、宣言的で直感的なエラーハンドリングを実現します。

基本的な動作としては、notFound()を実行するとNext.jsの特別な404ページ(デフォルトまたはカスタム)へ自動的にリダイレクトされます。重要なのは、これが単なるページ遷移ではなく、HTTPステータスコード404を正しく返すという点です。

// 最もシンプルな例
import { notFound } from 'next/navigation';

export default function Page() {
  const user = null; // ユーザーが見つからない場合

  if (!user) {
    notFound(); // 404ページを表示
  }

  return <div>User Profile</div>;
}

2. 業務でのユースケース

実際の開発現場では、様々なシーンでnotFound()が活躍します。

ユースケース1:動的ルートでのデータ検証

ブログ記事や商品詳細ページなど、URLパラメータから指定されたリソースが存在しない場合、404を返す必要があります。データベースクエリの結果に基づいて判定することが一般的です。

ユースケース2:ユーザー権限チェック

ログイン中のユーザーが特定のページにアクセス権限を持たない場合、見つかりませんという表現で404を返す方が、セキュリティ的に情報を隠蔽できます。

ユースケース3:A/Bテストやフィーチャーフラグ

特定のユーザーグループには新機能を見せず、他のグループには404を返すといった条件分岐が必要な場合があります。

ユースケース4:廃止されたコンテンツの処理

旧バージョンのAPIエンドポイントやレガシーページへのアクセスを適切に404で処理します。

3. 実装コード:業務レベルの実例

3-1. データベースクエリ結果に基づく404処理

// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation';
import { getBlogPost } from '@/lib/db';
import { BlogPost } from '@/types/blog';

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

export async function generateStaticParams() {
  // ビルド時に存在するスラッグを事前生成
  const posts = await getBlogPost();
  return posts.map((post: BlogPost) => ({
    slug: post.slug,
  }));
}

export async function generateMetadata({ params }: BlogPageProps) {
  const post = await getBlogPost(params.slug);
  
  if (!post) {
    return {
      title: 'ページが見つかりません',
    };
  }

  return {
    title: post.title,
    description: post.excerpt,
  };
}

export default async function BlogPage({ params }: BlogPageProps) {
  const post = await getBlogPost(params.slug);

  if (!post) {
    notFound();
  }

  return (
    <article>
      <h1>{post.title}</h1>
      <div>{post.content}</div>
    </article>
  );
}

3-2. API層での検証とエラーハンドリング

// app/api/users/[id]/route.ts
import { notFound } from 'next/navigation';
import { getUserById, deleteUser } from '@/lib/user-service';
import { verifyAuth } from '@/lib/auth';
import { NextRequest, NextResponse } from 'next/server';

export async function GET(
  request: NextRequest,
  { params }: { params: { id: string } }
) {
  try {
    const user = await getUserById(params.id);

    if (!user) {
      return NextResponse.json(
        { error: 'User not found' },
        { status: 404 }
      );
    }

    return NextResponse.json(user);
  } catch (error) {
    return NextResponse.json(
      { error: 'Internal server error' },
      { status: 500 }
    );
  }
}

export async function DELETE(
  request: NextRequest,
  { params }: { params: { id: string } }
) {
  try {
    const authToken = request.headers.get('authorization');
    const auth = await verifyAuth(authToken);

    if (!auth) {
      return NextResponse.json(
        { error: 'Unauthorized' },
        { status: 401 }
      );
    }

    const user = await getUserById(params.id);

    if (!user) {
      return NextResponse.json(
        { error: 'User not found' },
        { status: 404 }
      );
    }

    await deleteUser(params.id);

    return NextResponse.json({ success: true });
  } catch (error) {
    return NextResponse.json(
      { error: 'Internal server error' },
      { status: 500 }
    );
  }
}

3-3. Server Componentでの権限チェック

// app/admin/dashboard/page.tsx
import { notFound } from 'next/navigation';
import { getSession } from '@/lib/auth';
import { AdminDashboard } from '@/components/admin-dashboard';

export default async function AdminPage() {
  const session = await getSession();

  // ログインしていない、または管理者権限がない場合
  if (!session || !session.user.isAdmin) {
    notFound();
  }

  return <AdminDashboard />;
}

3-4. カスタム404ページの実装

// app/not-found.tsx
import Link from 'next/link';
import { NotFoundIllustration } from '@/components/illustrations';

export default function NotFound() {
  return (
    <div className=\"flex flex-col items-center justify-center min-h-screen\">
      <NotFoundIllustration />
      <h1 className=\"text-4xl font-bold mt-8\">404 - ページが見つかりません</h1>
      <p className=\"text-gray-600 mt-4\">
        申し訳ありません。お探しのページは存在しないか、削除されています。
      </p>
      <div className=\"mt-8 space-x-4\">
        <Link
          href=\"/\"
          className=\"px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700\"
        >
          ホームへ戻る
        </Link>
        <Link
          href=\"/blog\"
          className=\"px-6 py-2 border border-blue-600 text-blue-600 rounded-lg hover:bg-blue-50\"
        >
          ブログを見る
        </Link>
      </div>
    </div>
  );
}

4. よくある応用パターン

パターン1:複数の検証条件を組み合わせる

// app/products/[id]/page.tsx
import { notFound } from 'next/navigation';
import { getProduct } from '@/lib/products';
import { isProductPublished } from '@/lib/validation';

export default async function ProductPage({
  params,
}: {
  params: { id: string };
}) {
  const product = await getProduct(params.id);

  // 複数の条件でnotFoundを実行
  if (!product || !isProductPublished(product)) {
    notFound();
  }

  return (
    <div>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      <span className=\"text-2xl font-bold\">¥{product.price}</span>
    </div>
  );
}

パターン2:フィーチャーフラグとの組み合わせ

// app/new-feature/page.tsx
import { notFound } from 'next/navigation';
import { isFeatureEnabled } from '@/lib/feature-flags';
import { getSession } from '@/lib/auth';

export default async function NewFeaturePage() {
  const session = await getSession();

  // ユーザーに対して新機能が有効化されていない場合
  const isEnabled = await isFeatureEnabled('new-feature', session?.user?.id);

  if (!isEnabled) {
    notFound();
  }

  return (
    <div>
      <h1>新機能へようこそ</h1>
      <p>このページはベータテスト中です</p>
    </div>
  );
}

パターン3:キャッシュと組み合わせた最適化

// app/articles/[id]/page.tsx
import { notFound } from 'next/navigation';
import { getArticle, getCachedArticle } from '@/lib/articles';
import { revalidateTag } from 'next/cache';

export const revalidate = 3600; // 1時間ごとに再検証

export default async function ArticlePage({
  params,
}: {
  params: { id: string };
}) {
  // まずキャッシュから取得
  let article = getCachedArticle(params.id);

  // キャッシュがない場合はDBから取得
  if (!article) {
    article = await getArticle(params.id);
    
    if (!article) {
      notFound();
    }

    // キャッシュを再検証
    revalidateTag(`article-${params.id}`);
  }

  return (
    <article>
      <h1>{article.title}</h1>
      <div className=\"prose\">{article.body}</div>
    </article>
  );
}

パターン4:ロケーション別の条件分岐

// app/regional/[region]/page.tsx
import { notFound } from 'next/navigation';
import { isRegionSupported, getRegionalContent } from '@/lib/regional';

const SUPPORTED_REGIONS = ['jp', 'us', 'eu', 'sg'];

export default async function RegionalPage({
  params,
}: {
  params: { region: string };
}) {
  // サポート対象外の地域の場合
  if (!SUPPORTED_REGIONS.includes(params.region)) {
    notFound();
  }

  const content = await getRegionalContent(params.region);

  if (!content) {
    notFound();
  }

  return (
    <div>
      <h1>{content.title}</h1>
      <p>{content.description}</p>
    </div>
  );
}

5. 注意点と落とし穴

注意点1:notFound()はServer ComponentでのみServer Componentで使用

notFound()はServer Componentで使用する関数です。Client Componentで使用したい場合は、別のエラーハンドリング方式(例えばコンポーネントの条件分岐やリダイレクト)を検討する必要があります。

// ❌ 間違い - Client Componentで使用
'use client';
import { notFound } from 'next/navigation';

export default function ClientComponent() {
  // notFound()はClient Componentでは動作しません
  return <div></div>;
}

// ✅ 正しい - Server Componentで使用
import { notFound } from 'next/navigation';

export default async function ServerComponent() {
  const data = await fetchData();
  
  if (!data) {
    notFound();
  }

  return <div></div>;
}

注意点2:SEOへの影響を考慮

HTTP 404ステータスコードは検索エンジンのクローラーにページが存在しないことを伝えます。これは意図した動作ですが、本来存在すべきページを誤って404にしないよう注意が必要です。

注意点3:generateStaticParamsとの連携

静的生成(Static Generation)を使う場合、generateStaticParams()で返すパスのみがビルド時に生成されます。その他のパスへのアクセスは自動的に404になるため、定期的な再検証やオンデマンド静的生成を検討しましょう。

// app/items/[id]/page.tsx
export const dynamicParams = true; // 事前生成されていないパスも許可

export async function generateStaticParams() {
  // 人気のあるアイテムのみ事前生成
  const popularItems = await getPopularItems();
  return popularItems.map((item: Item) => ({
    id: item.id,
  }));
}

export default async function ItemPage({ params }: { params: { id: string } }) {
  const item = await getItem(params.id);

  if (!item) {
    notFound();
  }

  return <div>{item.name}</div>;
}

注意点4:キャッシュレイアウトの問題

親レイアウトでデータ取得している場合、子ページでnotFound()を呼ぶと親のキャッシュが無駄になる可能性があります。可能な限り早い段階でデータの存在確認をしましょう。

注意点5:ユーザー体験の配慮

404ページが表示されたとき、ユーザーが次にどこへ進むべきかわからないことがあります。カスタム404ページには適切なナビゲーションリンクやサイト検索機能を組み込みましょう。

6. 実装時のベストプラクティス

エラーロギングの実装

// lib/logging.ts
export async function logNotFound(
  path: string,
  reason: string,
  context?: Record<string, unknown>
) {
  const timestamp = new Date().toISOString();
  
  // ログサービス(例:Sentry、LogRocket等)に送信
  await fetch('/api/logs', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      type: 'not_found',
      path,
      reason,
      context,
      timestamp,
    }),
  });
}

// app/products/[id]/page.tsx
import { notFound } from 'next/navigation';
import { getProduct } from '@/lib/products';
import { logNotFound } from '@/lib/logging';

export default async function ProductPage({
  params,
}: {
  params: { id: string };
}) {
  const product = await getProduct(params.id);

  if (!product) {
    await logNotFound(
      `/products/${params.id}`,
      'Product not found in database',
      { productId: params.id }
    );
    notFound();
  }

  return <div>{product.name}</div>;
}

段階的なデータ検証

// app/users/[username]/settings/page.tsx
import { notFound } from 'next/navigation';
import { getUserByUsername } from '@/lib/users';
import { getSession } from '@/lib/auth';

export default async function UserSettingsPage({
  params,
}: {
  params: { username: string };
}) {
  // ステップ1:ユーザーの存在確認
  const user = await getUserByUsername(params.username);
  if (!user) {
    notFound();
  }

  // ステップ2:権限の確認
  const session = await getSession();
  if (!session || session.user.id !== user.id) {
    notFound();
  }

  // ステップ3:アカウントステータスの確認
  if (user.status === 'suspended') {
    notFound();
  }

  return (
    <div>
      <h1>{user.name}のアカウント設定</h1>
      {/* 設定フォーム */}
    </div>
  );
}

まとめ

Next.jsのnotFound()関数は、シンプルながら強力なエラーハンドリングメカニズムです。業務レベルのアプリケーション開発では、以下の点を心に留めておくことが重要です。

  • 正確性:データベースクエリ結果に基づいて適切に404を返す
  • セキュリティ:権限チェックと組み合わせて情報を保護する
  • パフォーマンス:キャッシュ戦略と連携させて効率的に動作させる
  • ユーザー体験:カスタム404ページで適切なナビゲーションを提供する
  • 可視性:404が発生した理由をログに記録して監視する

これらを実践することで、検索エンジン最適化(SEO)にも優れた、堅牢で保守しやすいNext.jsアプリケーションを構築できます。実装する際は、プロジェクトの要件に合わせて、紹介したパターンを参考にカスタマイズしてください。

タイトルとURLをコピーしました