はじめに
Next.js App Routerには、ファイルベースのエラーハンドリング機能が組み込まれています。error.tsx、global-error.tsx、not-found.tsxを使って、エラー時のユーザー体験を最適化する方法を解説します。
エラーハンドリングファイルの種類
| ファイル | 用途 | スコープ |
|---|
| error.tsx | ルートセグメントのエラー | 同じセグメント以下 |
| global-error.tsx | アプリ全体のエラー | ルートレイアウト含む |
| not-found.tsx | 404エラー | 同じセグメント以下 |
| 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.tsx | 404エラー | 不要 |
参考文献