error.tsx は Route Segment ごとの Client Component の Error Boundary であり、対応する layout.tsx よりも下位のツリーのエラーのみ捕捉する。
global-error.tsx はルート layout.tsx 自体が投げたエラーを捕捉する唯一の手段で、独自の <html> と <body> を返す必要がある。
not-found.tsx は notFound() 関数か URL 未マッチ時に発火し、Next.js 16 では metadata を静的にプリレンダリング可能になった。
Server Actions では throw ではなく { ok: false, error } の Discriminated Union を返し、useActionState で受けるのが型安全で防御的。
本番監視は instrumentation.ts の onRequestError にサーバー側で集約し、Sentry の captureRequestError をワンライナーで接続する。
Server Component 内の未捕捉 Promise reject は onRequestError にしか届かないため、Client Boundary だけでは監視が不十分。
目次
Next.js 16 のエラーモデルを理解する
error.tsx はどこに置く?セグメント境界の設計
global-error.tsx と error.tsx の違いは?
not-found.tsx と notFound() の正しい使い方
Server Actions でエラーをどう処理するのが型安全?
Next.js で Sentry を設定するにはどうすればいい?
instrumentation.ts の onRequestError と分散トレーシング
現場でよく見るアンチパターン5つ
Next.js 16 のエラーモデルを理解する
App Router のエラーは、レンダリング段階(Server / Client)と発生位置(Route Segment / Root Layout / Server Action)で処理経路が完全に分かれます。まず全体像を Next.js 公式のエラーハンドリング解説 と対応させて整理しましょう。Server Component が同期的にスロー、あるいは await したPromise がリジェクトした場合、Next.js は React の renderToPipeableStream の onError を経由して最も近い error.tsx(実体は Client Boundary)に到達させます。Client Component 内で useEffect やイベントハンドラから投げられたエラーも同じ境界で受けられます。
一方、layout.tsx や template.tsx の中で発生したエラーは、その階層の error.tsx では受けられません。Error Boundary は React の仕組み上「自分より上のレイアウトが正常に描画されている」ことを前提とするため、Layout 自身のエラーは1段上の error.tsx、あるいはルート層の global-error.tsx でしか受けられないのです。ここが最も誤解の多いポイントで(私も最初にハマった箇所です)、本記事の global-error.tsx セクション で図解します。
Next.js 16 からは、instrumentation.ts の onRequestError フック が安定版となり、あらゆるサーバー側エラー(Server Component / Route Handler / Middleware / Server Action)が単一のフックに集約されます。監視 SaaS への転送はこの1点で処理するのが2026年のベストプラクティスです。
error.tsx はどこに置く?セグメント境界の設計
error.tsx は 置きたい Route Segment に配置し、必ず 'use client' を宣言する のが原則です。Next.js は各セグメントの直下に <ErrorBoundary> を自動で挿入し、error.tsx の default export をその fallback として使います。私の経験則では、ダッシュボードのようにサブページごとに独立したデータフェッチがあるアプリでは、Route Group ごとに1枚ずつ配置する のが最も再現性が高いです。トップに1枚だけ置くと、部分的な失敗(例:グラフのAPI だけタイムアウト)で画面全体が再マウントされ、UX が壊れます。実際、去年のリライト案件でここを見落として1週間デバッグする羽目になりました。
// app/(dashboard)/reports/error.tsx
'use client';
import { useEffect } from 'react';
type Props = {
error: Error & { digest?: string };
reset: () => void;
};
export default function ReportsError({ error, reset }: Props) {
useEffect(() => {
// クライアント側で捕捉したエラーもサーバーに送りたい場合
fetch('/api/log-client-error', {
method: 'POST',
body: JSON.stringify({
message: error.message,
digest: error.digest, // サーバー側ログとの突き合わせ用
stack: error.stack,
}),
});
}, [error]);
return (
<div role="alert" className="rounded border border-red-300 p-6">
<h2 className="text-lg font-semibold">レポートを表示できませんでした</h2>
<p className="mt-2 text-sm text-neutral-600">
参照ID: {error.digest ?? 'unknown'}
</p>
<button
onClick={() => reset()}
className="mt-4 rounded bg-black px-4 py-2 text-white"
>
再試行
</button>
</div>
);
}
ヒント: error.digest はサーバー側でエラーメッセージから生成されたハッシュで、本番ではメッセージ本体はクライアントに送られません。監視ツールで検索できるようUIに表示しておくと、ユーザーからの問い合わせを1分で特定できるようになります。
reset() の落とし穴
reset() は Error Boundary を再マウントし、境界内のツリーを再レンダリングします。ただし fetch のキャッシュ状態は変わらない ため、原因が API のレスポンスにある場合は router.refresh() と併用する必要があります。Cache Components と use cache の使い方 を採用しているアプリでは、revalidateTag() をエラー種別に応じて呼び分けるとより堅牢です(ここ、意外と忘れがち)。
global-error.tsx と error.tsx の違いは?
ざっくり言うと、error.tsx はセグメントの Layout の中に、global-error.tsx は Root Layout そのものの外側にマウントされる のが最大の違いです。したがって global-error.tsx は独自の <html> と <body> を返す必要があり、本番ビルドでのみ有効です。開発モードでは Next.js のエラーオーバーレイが優先されます。
特性 error.tsx global-error.tsx
配置場所 各 Route Segment app/ 直下のみ
<html>/<body> の記述 不要(Layoutに含まれる) 必須(Layoutが失敗しているため)
Layout 自身のエラー捕捉 捕捉できない 捕捉できる
開発時の挙動 オーバーレイと共存 オーバーレイが優先
metadata 適用 親Layout経由で適用 適用されない(プレーンHTML)
reset() で戻る先 境界内の再レンダリング ルートツリー全体の再マウント
// app/global-error.tsx
'use client';
import * as Sentry from '@sentry/nextjs';
import { useEffect } from 'react';
export default function GlobalError({
error,
}: {
error: Error & { digest?: string };
}) {
useEffect(() => {
Sentry.captureException(error);
}, [error]);
return (
<html lang="ja">
<body>
<h1>サービスに接続できません</h1>
<p>しばらく時間をおいて再度お試しください。</p>
</body>
</html>
);
}
注意: global-error.tsx にスタイルシートを import しても、Layout が壊れているケースでは適用されない可能性があります。CSS-in-JS ではなく、インラインの style 属性か <style> タグでフォールバックの見た目を保証してください。
not-found.tsx と notFound() の正しい使い方
not-found.tsx は、next/navigation の notFound() 関数が呼ばれた場合、および Route が一致しなかった場合の両方で発火します。Next.js 16 では generateMetadata が not-found セグメントにも適用され、404ページに独自の <title> と Open Graph 画像を設定できるようになりました。SEO 観点ではこれが地味に効いてきます。旧来のカスタム404では動的に <title> を変えられなかった問題が、ようやく解消されたわけです。
// app/products/[id]/page.tsx
import { notFound } from 'next/navigation';
import { getProduct } from '@/lib/db';
export default async function ProductPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const product = await getProduct(id);
// 存在しなければ 404 に切り替え。ステータスコードも自動で 404 になる。
if (!product) notFound();
return <article>{product.name}</article>;
}
// app/products/[id]/not-found.tsx
import Link from 'next/link';
export const metadata = {
title: '商品が見つかりません',
};
export default function ProductNotFound() {
return (
<section>
<h2>お探しの商品はご用意がありません</h2>
<Link href="/products">商品一覧へ戻る</Link>
</section>
);
}
notFound() を throw のように使わない
notFound() は内部的に特殊な NEXT_NOT_FOUND シンボルを throw しますが、try/catch で握りつぶすと 404 になりません 。データアクセス層でエラーハンドリングをまとめている場合は、この Symbol を再スローする必要があります。Server Actions のセキュリティ実装ガイド で紹介した Zod バリデーションと組み合わせるときは特に注意してください。私も最初にこれで500を返してしまい、ステージングで気付いたクチです。
Server Actions でエラーをどう処理するのが型安全?
Server Actions ではエラーを throw せず、Discriminated Union を返り値として明示する のが2026年の推奨パターンです。理由は3つあります。第一に、throw したエラーは本番では digest 化されて詳細がクライアントに届かず、UI で個別に表示できません。第二に、React 19 の useActionState と組み合わせた際に、成功/失敗の型を推論しやすくなります。第三に、Zod のようなバリデータのエラーをフォーム項目ごとに紐付けやすくなります。
// app/checkout/actions.ts
'use server';
import { z } from 'zod';
import { redirect } from 'next/navigation';
const CheckoutSchema = z.object({
email: z.string().email(),
cardNumber: z.string().regex(/^\d{16}$/),
});
type CheckoutResult =
| { ok: true; orderId: string }
| { ok: false; fieldErrors: Record<string, string[]> }
| { ok: false; formError: string };
export async function checkout(
_prev: CheckoutResult | null,
formData: FormData,
): Promise<CheckoutResult> {
const parsed = CheckoutSchema.safeParse({
email: formData.get('email'),
cardNumber: formData.get('cardNumber'),
});
if (!parsed.success) {
return { ok: false, fieldErrors: parsed.error.flatten().fieldErrors };
}
try {
const orderId = await chargeAndCreateOrder(parsed.data);
redirect(`/checkout/success?id=${orderId}`);
} catch (err) {
// redirect() 由来のスローは再スローする
if (err instanceof Error && err.message === 'NEXT_REDIRECT') throw err;
return { ok: false, formError: '決済処理に失敗しました。' };
}
return { ok: true, orderId: '' }; // unreachable
}
クライアント側では useActionState でこの結果を受けます。Streaming SSR と Suspense のパフォーマンス最適化 と併用する場合、<form> は Suspense 境界の内側に置いておくと、フォームだけ独立して再レンダリングされます。
// app/checkout/CheckoutForm.tsx
'use client';
import { useActionState } from 'react';
import { checkout } from './actions';
export function CheckoutForm() {
const [state, formAction, pending] = useActionState(checkout, null);
return (
<form action={formAction}>
<input name="email" aria-invalid={!!state && !state.ok && 'fieldErrors' in state && !!state.fieldErrors.email} />
{state && !state.ok && 'formError' in state && (
<p role="alert">{state.formError}</p>
)}
<button disabled={pending}>{pending ? '処理中…' : '注文を確定'}</button>
</form>
);
}
Next.js で Sentry を設定するにはどうすればいい?
Sentry の Next.js 16 対応SDKは @sentry/nextjs v8 以降で、公式のセットアップドキュメント にある通り、sentry.client.config.ts、sentry.server.config.ts、sentry.edge.config.ts の3ファイルに分けます。以前必要だった next.config.js のラッパー(withSentryConfig)はまだ動作しますが、Turbopack 併用時はソースマップアップロードの挙動が変わっており、ビルド後に sentry-cli sourcemaps upload をCIから叩く方式に統一するのが安全です。
// sentry.server.config.ts
import * as Sentry from '@sentry/nextjs';
Sentry.init({
dsn: process.env.SENTRY_DSN,
tracesSampleRate: 0.1,
// Server Component の未捕捉Promiseもキャプチャ
integrations: [Sentry.captureConsoleIntegration({ levels: ['error'] })],
});
// instrumentation.ts
import * as Sentry from '@sentry/nextjs';
export async function register() {
if (process.env.NEXT_RUNTIME === 'nodejs') {
await import('./sentry.server.config');
} else if (process.env.NEXT_RUNTIME === 'edge') {
await import('./sentry.edge.config');
}
}
export const onRequestError = Sentry.captureRequestError;
備考: tracesSampleRate をいきなり 1.0 にすると Sentry のイベントクォータをすぐに使い切ります。まずは 0.1(10%)から開始し、Vercel の Analytics で異常トラフィックを確認しながら調整するのが実務的です。私は前職のプロジェクトで1日で月次クォータを溶かした経験があるので、ここは強めに勧めておきます。
instrumentation.ts の onRequestError と分散トレーシング
onRequestError フックは、Server Component、Route Handler、Middleware、Server Action のいずれで発生した未捕捉エラーも1本のパスに集約します。第2引数には path、method、headers、routerKind("App Router" | "Pages Router")、routePath、routeType("render" | "route" | "action" | "middleware")が入るため、どの経路のエラーかを高い解像度でタグ付けできます。
// instrumentation.ts
export async function onRequestError(
error: unknown,
request: {
path: string;
method: string;
headers: Record<string, string>;
},
context: {
routerKind: 'App Router' | 'Pages Router';
routePath: string;
routeType: 'render' | 'route' | 'action' | 'middleware';
revalidateReason?: 'on-demand' | 'stale';
renderSource?: 'react-server-components' | 'server-rendering';
},
) {
// 独自の構造化ログ(Datadog / Grafana Loki 向け)
console.error(
JSON.stringify({
severity: 'ERROR',
message: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
routePath: context.routePath,
routeType: context.routeType,
method: request.method,
traceId: request.headers['x-vercel-id'] ?? request.headers['traceparent'],
}),
);
}
x-vercel-id ヘッダーは Vercel が付与するリクエスト固有IDで、Vercel のログエクスプローラと突き合わせられます。オンプレ/別クラウドで運用する場合は W3C Trace Context の traceparent をそのまま採用すると、OpenTelemetry コレクタ経由での分散トレーシングが素直に繋がります。ちなみに OpenTelemetry の JavaScript SDK を Node ランタイム側で自動計装しておくと、DB クエリのスパンまで一緒に見えるようになるのでおすすめです。
現場でよく見るアンチパターン5つ
1. layout.tsx で fetch を await して error.tsx に頼る
Layout 内でスローされたエラーは同階層の error.tsx では受けられません。Layout ではデータフェッチを避け、必要なら Suspense 境界の内側の Server Component で await してください。
2. Server Action で try/catch を書き忘れ、redirect を throw で潰す
redirect() と notFound() は内部で特殊な Error を投げます。try/catch の中で呼ぶと 302 も 404 も起きず、"Something went wrong" ページになります。err.message === 'NEXT_REDIRECT' 判定で再スローが必須です。
3. Client Boundary だけで監視が完結していると思い込む
Server Component のエラーは Client の Boundary に到達する前に digest 化され、詳細が捨てられます。監視は必ずサーバー側の onRequestError または各Server Component の try/catch に依存させてください。
4. global-error.tsx を配置していない
Root Layout に軽微なバグがある場合、ユーザーには Vercel のデフォルトエラーページ(真っ白な "Application error" 画面)が表示されます。ブランドを守るためにも必須の1枚です。
5. digest をUIから消してしまう
error.digest をユーザーに見せたくない気持ちは分かりますが、これがないと問い合わせ対応が地獄になります。Middleware で相関ID を付与する 設計と組み合わせて、必ずどこかに表示してください。
よくある質問
error.tsx と ErrorBoundary は何が違うのですか?
error.tsx は Next.js のルーターがビルド時に自動生成する <ErrorBoundary> の fallback です。実体は React の Error Boundary ですが、Route Segment ごとに配置を宣言でき、reset() がルーター状態と統合される点が独自です。手動で <ErrorBoundary> を書く必要はほぼありません。
Server Component の中で try/catch を書いても大丈夫ですか?
問題ありません。ただし redirect()、notFound()、Cache Components の use cache 由来のスローは再スローが必要です。if ('digest' in err) throw err のように Next.js の内部エラーを判定して透過させるのが安全です。
開発モードで global-error.tsx が表示されないのはなぜですか?
開発モードでは Next.js のエラーオーバーレイが優先されるため、global-error.tsx は原則表示されません。挙動確認は next build && next start で本番ビルドを立ち上げるか、NODE_ENV=production で起動してください。
Middleware でスローしたエラーはどこで捕捉されますか?
Middleware は Edge Runtime で動作し、エラーは error.tsx ではなく onRequestError(routeType: 'middleware')に届きます。ユーザー向けには500応答が返るため、Middleware 内で NextResponse.redirect や NextResponse.rewrite で明示的にフォールバック先を指定するのが実務的です。
Sentry を使わずに Vercel だけで運用する場合の推奨は?
Vercel Log Drains で構造化JSONログを Datadog / Grafana / BetterStack に転送し、onRequestError 内で console.error(JSON.stringify(...)) するのが最もシンプルです。Vercel Observability Plus に加入すると、同じログを Vercel のダッシュボードでも検索可能になります。