Documentation Next.js

はじめに

Next.js App Routerには、ファイルベースのエラーハンドリング機能が組み込まれています。ユーザー体験を損なわない適切なエラー処理と、開発効率を高めるエラーハンドリングパターンを解説します。

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

ファイル用途スコープhtml/bodyタグ
error.tsxルートセグメントのエラー同じセグメント以下不要
not-found.tsx404エラー同じセグメント以下不要
global-error.tsxアプリ全体のエラールートレイアウト含む必須

not-found.tsx(404ページ)

グローバル404ページ

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

export const metadata: Metadata = {
  title: 'ページが見つかりません | MyApp',
  description: 'お探しのページは存在しないか、移動した可能性があります。',
};

export default function NotFound() {
  return (
    <div className="min-h-[60vh] flex items-center justify-center px-4">
      <div className="text-center max-w-md">
        <p className="text-9xl font-bold text-gray-200">404</p>
        <h1 className="mt-4 text-2xl font-bold text-gray-900">
          ページが見つかりません
        </h1>
        <p className="mt-4 text-gray-600">
          お探しのページは存在しないか、移動した可能性があります。
          URLが正しいかご確認ください。
        </p>
        <div className="mt-8 flex flex-col sm:flex-row gap-4 justify-center">
          <Link
            href="/"
            className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition"
          >
            ホームへ戻る
          </Link>
          <Link
            href="/contact"
            className="px-6 py-3 border border-gray-300 rounded-lg hover:bg-gray-50 transition"
          >
            お問い合わせ
          </Link>
        </div>
      </div>
    </div>
  );
}

notFound()関数の使用

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

interface Props {
  params: Promise<{ slug: string }>;
}

export default async function PostPage({ params }: Props) {
  const { slug } = await params;
  const post = await getPost(slug);

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

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

セグメント固有の404ページ

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

export default function BlogNotFound() {
  return (
    <div className="p-8 text-center">
      <h2 className="text-2xl font-bold text-gray-800">
        記事が見つかりません
      </h2>
      <p className="mt-4 text-gray-600">
        お探しの記事は公開終了したか、URLが変更された可能性があります。
      </p>
      <Link
        href="/blog"
        className="mt-6 inline-block px-6 py-3 bg-blue-600 text-white rounded-lg"
      >
        記事一覧へ戻る
      </Link>
    </div>
  );
}

error.tsx(セグメントエラー)

基本的なerror.tsx

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

import { useEffect } from 'react';

interface Props {
  error: Error & { digest?: string };
  reset: () => void;
}

export default function Error({ error, reset }: Props) {
  useEffect(() => {
    // エラーログを外部サービスに送信
    console.error('Application Error:', error);
  }, [error]);

  return (
    <div className="min-h-[400px] flex items-center justify-center p-4">
      <div className="text-center max-w-md">
        <div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mx-auto">
          <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>

        <h2 className="mt-4 text-xl font-bold text-gray-900">
          エラーが発生しました
        </h2>
        <p className="mt-2 text-gray-600">
          申し訳ありませんが、予期せぬ問題が発生しました。
        </p>

        {process.env.NODE_ENV === 'development' && (
          <details className="mt-4 text-left bg-gray-100 rounded-lg p-4">
            <summary className="cursor-pointer text-sm font-medium text-gray-700">
              エラー詳細(開発環境のみ)
            </summary>
            <pre className="mt-2 text-xs overflow-auto whitespace-pre-wrap break-words">
              {error.message}
              {error.stack && `\n\n${error.stack}`}
            </pre>
          </details>
        )}

        <div className="mt-6 flex flex-col sm:flex-row gap-3 justify-center">
          <button
            onClick={reset}
            className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
          >
            もう一度試す
          </button>
          <a
            href="/"
            className="px-6 py-2 border border-gray-300 rounded-lg hover:bg-gray-50"
          >
            ホームへ戻る
          </a>
        </div>
      </div>
    </div>
  );
}

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

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

import { useRouter } from 'next/navigation';

interface Props {
  error: Error & { digest?: string };
  reset: () => void;
}

export default function DashboardError({ error, reset }: Props) {
  const router = useRouter();

  const handleRetry = () => {
    router.refresh(); // サーバーデータを再取得
    reset();
  };

  return (
    <div className="p-6 bg-yellow-50 border border-yellow-200 rounded-lg">
      <div className="flex items-start gap-4">
        <div className="flex-shrink-0">
          <svg className="w-6 h-6 text-yellow-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>
        <div className="flex-1">
          <h2 className="text-lg font-semibold text-yellow-800">
            ダッシュボードの読み込みに失敗しました
          </h2>
          <p className="mt-1 text-yellow-700">
            データの取得中にエラーが発生しました。再度お試しください。
          </p>
          <button
            onClick={handleRetry}
            className="mt-4 px-4 py-2 bg-yellow-600 text-white rounded hover:bg-yellow-700"
          >
            再読み込み
          </button>
        </div>
      </div>
    </div>
  );
}

global-error.tsx(アプリ全体のエラー)

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

import { useEffect } from 'react';

interface Props {
  error: Error & { digest?: string };
  reset: () => void;
}

export default function GlobalError({ error, reset }: Props) {
  useEffect(() => {
    // 重大なエラーをログに記録
    console.error('Global Error:', error);
  }, [error]);

  return (
    <html lang="ja">
      <body>
        <div className="min-h-screen flex items-center justify-center bg-gray-50 p-4">
          <div className="bg-white p-8 rounded-xl shadow-lg max-w-md w-full text-center">
            <div className="w-20 h-20 bg-red-100 rounded-full flex items-center justify-center mx-auto">
              <svg className="w-10 h-10 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="mt-6 text-2xl font-bold text-gray-900">
              重大なエラーが発生しました
            </h1>
            <p className="mt-3 text-gray-600">
              アプリケーションの読み込み中に問題が発生しました。
              ページを再読み込みしてください。
            </p>

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

カスタムエラークラス

// lib/errors.ts
export class AppError extends Error {
  constructor(
    message: string,
    public code: string,
    public statusCode: number = 500,
    public isOperational: boolean = true
  ) {
    super(message);
    this.name = 'AppError';
    Error.captureStackTrace(this, this.constructor);
  }
}

export class ValidationError extends AppError {
  constructor(
    message: string,
    public field?: string
  ) {
    super(message, 'VALIDATION_ERROR', 400);
    this.name = 'ValidationError';
  }
}

export class NotFoundError extends AppError {
  constructor(resource: string) {
    super(`${resource}が見つかりません`, 'NOT_FOUND', 404);
    this.name = 'NotFoundError';
  }
}

export class UnauthorizedError extends AppError {
  constructor(message = '認証が必要です') {
    super(message, 'UNAUTHORIZED', 401);
    this.name = 'UnauthorizedError';
  }
}

export class ForbiddenError extends AppError {
  constructor(message = 'アクセス権限がありません') {
    super(message, 'FORBIDDEN', 403);
    this.name = 'ForbiddenError';
  }
}

export class ConflictError extends AppError {
  constructor(message: string) {
    super(message, 'CONFLICT', 409);
    this.name = 'ConflictError';
  }
}

Route Handlersでのエラー処理

エラーハンドラーユーティリティ

// lib/api-error-handler.ts
import { NextResponse } from 'next/server';
import { AppError } from './errors';

interface ErrorResponse {
  error: {
    message: string;
    code: string;
  };
}

export function handleApiError(error: unknown): NextResponse<ErrorResponse> {
  console.error('API Error:', error);

  // カスタムエラー
  if (error instanceof AppError) {
    return NextResponse.json(
      {
        error: {
          message: error.message,
          code: error.code,
        },
      },
      { status: error.statusCode }
    );
  }

  // Prismaエラー
  if (error instanceof Error && error.name === 'PrismaClientKnownRequestError') {
    const prismaError = error as { code: string };
    if (prismaError.code === 'P2002') {
      return NextResponse.json(
        {
          error: {
            message: '既に存在するデータです',
            code: 'DUPLICATE_ENTRY',
          },
        },
        { status: 409 }
      );
    }
    if (prismaError.code === 'P2025') {
      return NextResponse.json(
        {
          error: {
            message: 'データが見つかりません',
            code: 'NOT_FOUND',
          },
        },
        { status: 404 }
      );
    }
  }

  // その他のエラー
  return NextResponse.json(
    {
      error: {
        message: 'サーバーエラーが発生しました',
        code: 'INTERNAL_ERROR',
      },
    },
    { status: 500 }
  );
}

Route Handlerでの使用

// app/api/users/[id]/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { handleApiError } from '@/lib/api-error-handler';
import { NotFoundError, ValidationError } from '@/lib/errors';
import { prisma } from '@/lib/prisma';
import { z } from 'zod';

const updateUserSchema = z.object({
  name: z.string().min(1, '名前は必須です'),
  email: z.string().email('有効なメールアドレスを入力してください'),
});

interface Context {
  params: Promise<{ id: string }>;
}

export async function GET(request: NextRequest, context: Context) {
  try {
    const { id } = await context.params;
    const user = await prisma.user.findUnique({
      where: { id },
    });

    if (!user) {
      throw new NotFoundError('ユーザー');
    }

    return NextResponse.json(user);
  } catch (error) {
    return handleApiError(error);
  }
}

export async function PATCH(request: NextRequest, context: Context) {
  try {
    const { id } = await context.params;
    const body = await request.json();

    const result = updateUserSchema.safeParse(body);
    if (!result.success) {
      const firstError = result.error.errors[0];
      throw new ValidationError(firstError.message, firstError.path[0] as string);
    }

    const user = await prisma.user.update({
      where: { id },
      data: result.data,
    });

    return NextResponse.json(user);
  } catch (error) {
    return handleApiError(error);
  }
}

Server Actionsでのエラー処理

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

import { revalidatePath } from 'next/cache';
import { z } from 'zod';

const createUserSchema = z.object({
  name: z.string().min(2, '名前は2文字以上で入力してください'),
  email: z.string().email('有効なメールアドレスを入力してください'),
});

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

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

    const result = createUserSchema.safeParse(rawData);

    if (!result.success) {
      const firstError = result.error.errors[0];
      return {
        success: false,
        error: {
          message: firstError.message,
          field: firstError.path[0] as string,
        },
      };
    }

    const user = await prisma.user.create({
      data: result.data,
    });

    revalidatePath('/users');

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

    // Prismaのユニーク制約エラー
    if (error instanceof Error && error.message.includes('Unique constraint')) {
      return {
        success: false,
        error: {
          message: 'このメールアドレスは既に登録されています',
          field: 'email',
        },
      };
    }

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

クライアント側でのエラー表示

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

import { useActionState } from 'react';
import { createUser, ActionResult } from '@/actions/user';

const initialState: ActionResult = { success: false };

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

  return (
    <form action={formAction} className="space-y-4 max-w-md">
      <div>
        <label htmlFor="name" className="block text-sm font-medium text-gray-700">
          名前
        </label>
        <input
          type="text"
          id="name"
          name="name"
          className={`mt-1 block w-full rounded-md border px-3 py-2 ${
            state.error?.field === 'name' ? 'border-red-500' : 'border-gray-300'
          }`}
        />
        {state.error?.field === 'name' && (
          <p className="mt-1 text-sm text-red-600">{state.error.message}</p>
        )}
      </div>

      <div>
        <label htmlFor="email" className="block text-sm font-medium text-gray-700">
          メールアドレス
        </label>
        <input
          type="email"
          id="email"
          name="email"
          className={`mt-1 block w-full rounded-md border px-3 py-2 ${
            state.error?.field === 'email' ? 'border-red-500' : 'border-gray-300'
          }`}
        />
        {state.error?.field === 'email' && (
          <p className="mt-1 text-sm text-red-600">{state.error.message}</p>
        )}
      </div>

      {state.error && !state.error.field && (
        <div className="p-3 bg-red-50 border border-red-200 rounded-md">
          <p className="text-sm text-red-600">{state.error.message}</p>
        </div>
      )}

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

      <button
        type="submit"
        disabled={isPending}
        className="w-full py-2 px-4 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
      >
        {isPending ? '作成中...' : '作成'}
      </button>
    </form>
  );
}

まとめ

ファイル用途reset関数
not-found.tsx404エラーなし
error.tsxセグメントエラーあり
global-error.tsxアプリ全体エラーあり

参考文献

円