Documentation Next.js

はじめに

Next.js App Routerには、ファイルベースのエラーハンドリング機能が組み込まれています。error.tsx、global-error.tsx、not-found.tsxを使って、エラー時のユーザー体験を最適化する方法を解説します。

エラーハンドリングファイルの種類

ファイル用途スコープ
error.tsxルートセグメントのエラー同じセグメント以下
global-error.tsxアプリ全体のエラールートレイアウト含む
not-found.tsx404エラー同じセグメント以下
loading.tsxローディング状態同じセグメント以下

基本的なerror.tsx

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

import { useEffect } from 'react';

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  useEffect(() => {
    // エラーログをサービスに送信
    console.error('Error:', error);
  }, [error]);

  return (
    <div className="flex min-h-[400px] flex-col items-center justify-center p-4">
      <div className="text-center">
        <h2 className="text-2xl font-bold text-red-600 mb-4">
          エラーが発生しました
        </h2>
        <p className="text-gray-600 mb-6">
          申し訳ありませんが、問題が発生しました。
        </p>

        {process.env.NODE_ENV === 'development' && (
          <details className="mb-6 text-left max-w-lg mx-auto">
            <summary className="cursor-pointer text-sm text-gray-500">
              エラー詳細
            </summary>
            <pre className="mt-2 p-4 bg-gray-100 rounded text-xs overflow-auto">
              {error.message}
              {error.stack && `\n\n${error.stack}`}
            </pre>
          </details>
        )}

        <div className="space-x-4">
          <button
            onClick={reset}
            className="px-6 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
          >
            もう一度試す
          </button>
          <a
            href="/"
            className="px-6 py-2 border border-gray-300 rounded hover:bg-gray-50"
          >
            ホームへ戻る
          </a>
        </div>
      </div>
    </div>
  );
}

global-error.tsx

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

import { useEffect } from 'react';

export default function GlobalError({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  useEffect(() => {
    // 重大なエラーをログ
    console.error('Global Error:', error);
  }, [error]);

  return (
    <html lang="ja">
      <body>
        <div className="min-h-screen flex items-center justify-center bg-gray-100">
          <div className="bg-white p-8 rounded-lg shadow-lg max-w-md w-full text-center">
            <div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4">
              <svg
                className="w-8 h-8 text-red-600"
                fill="none"
                stroke="currentColor"
                viewBox="0 0 24 24"
              >
                <path
                  strokeLinecap="round"
                  strokeLinejoin="round"
                  strokeWidth={2}
                  d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
                />
              </svg>
            </div>

            <h1 className="text-2xl font-bold text-gray-900 mb-2">
              重大なエラーが発生しました
            </h1>
            <p className="text-gray-600 mb-6">
              アプリケーションの読み込み中に問題が発生しました。
            </p>

            <button
              onClick={reset}
              className="w-full py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition"
            >
              ページを再読み込み
            </button>
          </div>
        </div>
      </body>
    </html>
  );
}

not-found.tsx

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

export default function NotFound() {
  return (
    <div className="min-h-[60vh] flex items-center justify-center">
      <div className="text-center">
        <h1 className="text-6xl font-bold text-gray-300 mb-4">404</h1>
        <h2 className="text-2xl font-semibold text-gray-800 mb-4">
          ページが見つかりません
        </h2>
        <p className="text-gray-600 mb-8">
          お探しのページは存在しないか、移動した可能性があります。
        </p>
        <Link
          href="/"
          className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
        >
          ホームへ戻る
        </Link>
      </div>
    </div>
  );
}

ネストされたエラー境界

app/
├── error.tsx              # アプリ全体のフォールバック
├── layout.tsx
├── page.tsx
├── dashboard/
│   ├── error.tsx          # ダッシュボード専用エラー
│   ├── layout.tsx
│   ├── page.tsx
│   └── settings/
│       ├── error.tsx      # 設定ページ専用エラー
│       └── page.tsx
└── blog/
    ├── error.tsx          # ブログ専用エラー
    └── [slug]/
        ├── error.tsx      # 記事ページ専用エラー
        └── page.tsx

ダッシュボード専用エラー

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

import { useRouter } from 'next/navigation';

export default function DashboardError({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  const router = useRouter();

  const handleRetry = () => {
    // キャッシュをクリアして再取得
    router.refresh();
    reset();
  };

  return (
    <div className="p-6 bg-yellow-50 border border-yellow-200 rounded-lg">
      <h2 className="text-lg font-semibold text-yellow-800 mb-2">
        ダッシュボードの読み込みに失敗しました
      </h2>
      <p className="text-yellow-700 mb-4">
        データの取得中にエラーが発生しました。
      </p>
      <button
        onClick={handleRetry}
        className="px-4 py-2 bg-yellow-600 text-white rounded hover:bg-yellow-700"
      >
        再読み込み
      </button>
    </div>
  );
}

明示的なエラースロー

notFound()の使用

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

export default async function BlogPost({
  params,
}: {
  params: { slug: string };
}) {
  const post = await getPost(params.slug);

  if (!post) {
    notFound(); // not-found.tsxにルーティング
  }

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

カスタムエラーのスロー

// app/protected/page.tsx
import { redirect } from 'next/navigation';
import { getSession } from '@/lib/auth';

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

  if (!session) {
    redirect('/login');
  }

  if (!session.user.isAdmin) {
    throw new Error('アクセス権限がありません');
  }

  return <div>管理者専用コンテンツ</div>;
}

エラーモニタリング連携

Sentry統合

// lib/sentry.ts
import * as Sentry from '@sentry/nextjs';

export function reportError(error: Error, context?: Record<string, unknown>) {
  console.error(error);

  Sentry.captureException(error, {
    extra: context,
  });
}
// app/error.tsx
'use client';

import { useEffect } from 'react';
import { reportError } from '@/lib/sentry';

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  useEffect(() => {
    reportError(error, {
      digest: error.digest,
      pathname: window.location.pathname,
    });
  }, [error]);

  return (
    <div>
      <h2>エラーが発生しました</h2>
      <p>問題は自動的に報告されました。</p>
      <button onClick={reset}>再試行</button>
    </div>
  );
}

sentry.config.ts

// sentry.client.config.ts
import * as Sentry from '@sentry/nextjs';

Sentry.init({
  dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
  tracesSampleRate: 1.0,
  environment: process.env.NODE_ENV,
  integrations: [
    new Sentry.BrowserTracing({
      tracePropagationTargets: ['localhost', /^https:\/\/yoursite\.com/],
    }),
  ],
});

Server Actionsのエラーハンドリング

// actions/user.ts
'use server';

import { revalidatePath } from 'next/cache';

export interface ActionResult<T = unknown> {
  success: boolean;
  data?: T;
  error?: string;
}

export async function createUser(
  formData: FormData
): Promise<ActionResult<{ id: string }>> {
  try {
    const name = formData.get('name') as string;
    const email = formData.get('email') as string;

    // バリデーション
    if (!name || name.length < 2) {
      return {
        success: false,
        error: '名前は2文字以上で入力してください',
      };
    }

    // データベース操作
    const user = await db.user.create({
      data: { name, email },
    });

    revalidatePath('/users');

    return {
      success: true,
      data: { id: user.id },
    };
  } catch (error) {
    console.error('createUser error:', error);

    // 既知のエラーを分類
    if (error instanceof PrismaClientKnownRequestError) {
      if (error.code === 'P2002') {
        return {
          success: false,
          error: 'このメールアドレスは既に登録されています',
        };
      }
    }

    return {
      success: false,
      error: 'ユーザーの作成に失敗しました',
    };
  }
}

フォームでの使用

// components/UserForm.tsx
'use client';

import { useFormState } from 'react-dom';
import { createUser, ActionResult } from '@/actions/user';

const initialState: ActionResult = { success: false };

export function UserForm() {
  const [state, formAction] = useFormState(createUser, initialState);

  return (
    <form action={formAction} className="space-y-4">
      <div>
        <input
          type="text"
          name="name"
          placeholder="名前"
          className="border rounded px-3 py-2 w-full"
        />
      </div>

      <div>
        <input
          type="email"
          name="email"
          placeholder="メールアドレス"
          className="border rounded px-3 py-2 w-full"
        />
      </div>

      {state.error && (
        <div className="p-3 bg-red-50 text-red-600 rounded">
          {state.error}
        </div>
      )}

      {state.success && (
        <div className="p-3 bg-green-50 text-green-600 rounded">
          ユーザーを作成しました
        </div>
      )}

      <button
        type="submit"
        className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
      >
        作成
      </button>
    </form>
  );
}

エラーリカバリーパターン

自動リトライ

// components/DataFetcher.tsx
'use client';

import { useState, useEffect } from 'react';

interface Props {
  fetchFn: () => Promise<unknown>;
  maxRetries?: number;
  retryDelay?: number;
}

export function DataFetcher({
  fetchFn,
  maxRetries = 3,
  retryDelay = 1000,
}: Props) {
  const [data, setData] = useState(null);
  const [error, setError] = useState<Error | null>(null);
  const [retryCount, setRetryCount] = useState(0);

  useEffect(() => {
    const fetchData = async () => {
      try {
        const result = await fetchFn();
        setData(result);
        setError(null);
      } catch (err) {
        if (retryCount < maxRetries) {
          setTimeout(() => {
            setRetryCount((c) => c + 1);
          }, retryDelay * (retryCount + 1));
        } else {
          setError(err as Error);
        }
      }
    };

    fetchData();
  }, [fetchFn, retryCount, maxRetries, retryDelay]);

  if (error) {
    return <div>エラー: {error.message}</div>;
  }

  if (!data) {
    return <div>読み込み中... {retryCount > 0 && `(リトライ ${retryCount}回目)`}</div>;
  }

  return <div>{JSON.stringify(data)}</div>;
}

まとめ

ファイル用途html/bodyタグ
error.tsxセグメントエラー不要
global-error.tsxアプリ全体エラー必須
not-found.tsx404エラー不要

参考文献

円