はじめに
Next.js App Routerには、ファイルベースのエラーハンドリング機能が組み込まれています。ユーザー体験を損なわない適切なエラー処理と、開発効率を高めるエラーハンドリングパターンを解説します。
エラーハンドリングファイルの種類
| ファイル | 用途 | スコープ | html/bodyタグ |
|---|
| error.tsx | ルートセグメントのエラー | 同じセグメント以下 | 不要 |
| not-found.tsx | 404エラー | 同じセグメント以下 | 不要 |
| 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.tsx | 404エラー | なし |
| error.tsx | セグメントエラー | あり |
| global-error.tsx | アプリ全体エラー | あり |
参考文献