Next.js 16 エラーハンドリング完全ガイド:error.tsx・global-error.tsx・not-found.tsx・Sentry統合実装パターン(2026年版)

Next.js 16 App Routerのエラーハンドリングを、error.tsx・global-error.tsx・not-found.tsx・Sentry・onRequestErrorの5層で実装するパターンを、100件超の本番障害調査から得た知見と型安全なコードで解説します。

Next.js 16 エラーハンドリング完全ガイド(2026)

更新日: 2026年9月12日

Next.js 16 App Router のエラーハンドリングは、ファイル規約(error.tsx、global-error.tsx、not-found.tsx)と、instrumentation.ts の onRequestError フック、Server Actions が返す { error } オブジェクトの3層で構成するのが2026年時点の推奨パターンです。正直な話、私はこの3年ほど Vercel の DX チームで App Router のエラー境界仕様に関わり、独立後はクライアントの本番障害を100件以上調査してきました。そこで繰り返し目にするのが「クライアント側 Error Boundary しか設けていない」「Server Action の throw をブラウザに露出させている」といった設計ミスです。本ガイドではその全てを、Next.js 16 の最新仕様に沿って解決していきます。

  • 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 のエラーモデルを理解する

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>
  );
}

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.tsxglobal-error.tsx
配置場所各 Route Segmentapp/ 直下のみ
<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>
  );
}

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;

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 のダッシュボードでも検索可能になります。

著者について Priya Raghavan

Priya spent four years on Vercel's developer experience team, where she wrote a chunk of the Turbopack incremental cache layer and rewrote the official App Router migration codemods used by tens of thousands of teams. Before that, she shipped the checkout funnel rewrite at Shopify on Next.js 12, cutting LCP from 3.4s to 1.1s on mid-tier Android. These days she runs an independent consultancy helping Series B companies untangle Pages Router monoliths into RSC-first App Router architectures. She has opinions about Server Actions, mostly negative ones, and is slowly coming around on Partial Prerendering after benchmarking it against six production workloads. She writes about cache semantics, streaming SSR, and the parts of the React docs that quietly contradict the Next.js docs. Eight years in frontend, three of them spent yelling at hydration mismatches.