Next.js(App Router)のエラーハンドリングとは、「想定内のエラーは戻り値で返し、想定外のエラーは error.tsx などの特別なファイルで受け止める」という2段構えの仕組みです。404 は notFound() と not-found.tsx、読み込み中は loading.tsx、権限エラーは forbidden() / unauthorized() が担当し、ファイルを置くだけで画面が切り替わります。

この記事では Next.js 16.3.6 / React 19.3.0 / Node.js 22.22.2 で実際にアプリを作り、next dev と next build + next start の両方を Playwright で操作して結果を取得しました。本番ではエラー文が隠れて digest だけになる様子、各ファイルが返す HTTP ステータス、loading.tsx が何ミリ秒で届くかを、取得したテキストのまま載せています。16.3 で安定版になった retry と catchError(next/error) も、型チェックでつまずく点を含めて解説します。

Next.jsのエラーハンドリングの早見表

やりたいこと 使うもの 置き場所・呼び出し 本番のHTTPステータス(実測) 設定
想定外のエラーで代わりの画面を出す error.tsx ルートのフォルダ(app/boom/error.tsx) 500 不要
ルートレイアウトのエラーも受け止める global-error.tsx app/ 直下のみ 500 不要
データがないときに404画面を出す notFound() + not-found.tsx ページ内で notFound() 404(ストリーミング前) 不要
どのURLにも一致しないとき app/not-found.tsx app/ 直下 404 不要
複数ルートレイアウトの共通404 global-not-found.tsx app/ 直下 404 experimental.globalNotFound
読み込み中の表示 loading.tsx ルートのフォルダ 200(先に送信) 不要
権限がない(403) forbidden() + forbidden.tsx ページ・Server Action 等 403 experimental.authInterrupts
ログインしていない(401) unauthorized() + unauthorized.tsx ページ・Server Action 等 401 experimental.authInterrupts
画面の一部だけエラー表示にする catchError(next/error) 任意のコンポーネントを包む 500(検証環境) 不要(16.3で安定版)
エラー画面から再取得してやり直す retry() error.tsx / catchError の引数 ― 不要(16.3で安定版)
フォームの入力エラーを表示 Server Action の戻り値 + useActionState Server Action 200 不要

迷ったら「ユーザーの操作で起こりうるものは戻り値、バグや障害は throw して error.tsx」と覚えると整理しやすくなります。

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Next.jsのエラーハンドリング|error.tsxとnot-foundを実測」

検証環境と、16.3で変わった点

検証環境:next 16.3.6(最新)、react 19.3.0、Node.js 22.22.2、TypeScript 5.9.3、Playwright 1.56.1(Chromium 141)。

Next.js 16.3 で、error.tsx の再試行関数 retry と、コンポーネント単位のエラー境界 catchError が安定版になりました(16.2 では unstable_retry / unstable_catchError)。公式ドキュメントのコード例も reset から retry に置き換わっています。

関数 動き 使う場面
retry() サーバーから取り直してエラー境界の中身を再描画する ほとんどの場合(公式も推奨)
reset() エラー状態を消して再取得せずに再描画する クライアントだけの一時的な状態を戻したいとき

reset() は再取得しないため、Server Component で起きたエラーからは回復しません。古い記事で reset を使っている場合は、retry に読み替えるとよいです。

ファイルの入れ子関係(どのエラーをどこが受け止めるか)

1つのフォルダに特別なファイルを置くと、Next.js は次の順に包みます。

flowchart TD
  L[layout.tsx] --> T[template.tsx]
  T --> E[error.tsx のエラー境界]
  E --> LD[loading.tsx の Suspense]
  LD --> NF[not-found / forbidden / unauthorized の境界]
  NF --> P[page.tsx]

error.tsx は同じフォルダの layout.tsx を包みません。レイアウトで起きたエラーは1つ上の階層の error.tsx が受け止め、ルートレイアウトのエラーは global-error.tsx が受け止めます。エラーはいちばん近い親のエラー境界まで上がっていきます。

error.tsx:想定外のエラーを受け止める

コピペ用の error.tsx

// app/boom/error.tsx
'use client' // error.tsx は必ずクライアントコンポーネント

import { useEffect } from 'react'

export default function ErrorPage({
  error,
  retry,
}: {
  error: Error & { digest?: string }
  retry: () => void
}) {
  useEffect(() => {
    // ここでエラー監視サービスに送る
    console.error(error)
  }, [error])

  return (
    <div role="alert">
      <h2>読み込みに失敗しました</h2>
      <p id="msg">message: {error.message}</p>
      <p id="digest">digest: {error.digest ?? '(なし)'}</p>
      <button onClick={() => retry()}>もう一度試す</button>
    </div>
  )
}

わざとエラーを出すページです(メッセージに内部情報が入ってしまった想定)。

// app/boom/page.tsx
export const dynamic = 'force-dynamic'

export default async function BoomPage() {
  throw new Error('在庫DBに接続できません: host=db.internal user=app')
}

'use client' が必要なのは、retry ボタンのようにブラウザで動く処理を持つためです。1行目を消して next build すると、次のエラーでビルドが止まりました(抜粋)。

./app/boom/error.tsx:2:1
Error: app/boom/error.tsx must be a Client Component. Add the "use client" directive the top of the file to resolve this issue.

next dev と本番(next build + start)でエラー文はどう変わるか

同じ /boom を Playwright で開き、#msg と #digest のテキストを取得しました。

# next dev(開発)
[boom] status= 500 | message: 在庫DBに接続できません: host=db.internal user=app | digest: 2685266408

# next build + next start(本番)
[boom] status= 500 | message: Minified React error #441; visit https://react.dev/errors/441 for the full message or use the non-minified dev environment for full errors and additional helpful warnings. | digest: 3036563320

本番では、サーバーで起きたエラーの本文はブラウザに届かず、React のエラーコード #441 と digest だけになりました。#441 の本文は「Server Components の描画でエラーが起きた。機密情報の漏えいを防ぐため、本番ビルドでは詳細を省略している。digest プロパティで詳細をたどれる」という内容です(React の error codes で確認)。

一方、本番のサーバーログには元のメッセージと同じ digest が出ていました。

⨯ Error: 在庫DBに接続できません: host=db.internal user=app
    at b (.next/server/chunks/ssr/_16pke4x._.js:1:65) {
  digest: '3036563320'
}

画面の digest(3036563320)とサーバーログの digest が一致するので、問い合わせを受けたら digest でログを検索できます。エラー画面には digest を「エラーID」として表示しておくと、調査がスムーズです。

項目 next dev 本番(build + start)
Server Component のエラー文 元のメッセージが見える #441 の汎用メッセージに置き換わる
digest あり あり(サーバーログと一致)
Client Component のエラー文 元のメッセージ 元のメッセージのまま(下で検証)

Client Component のエラーは本番でも隠れない

ボタンを押すと描画中に例外を投げるクライアントコンポーネントでも確かめました。

// app/client-boom/page.tsx
'use client'

import { useState } from 'react'

export default function ClientBoom() {
  const [broken, setBroken] = useState(false)
  if (broken) {
    throw new Error('クライアント側の描画エラー: 表示用データが不正です')
  }
  return <button onClick={() => setBroken(true)}>壊す</button>
}
# 本番(build + start)
[client-boom] message: クライアント側の描画エラー: 表示用データが不正です | digest: (なし)

ブラウザで起きたエラーは、もともとブラウザにある情報なので本番でもそのまま表示され、digest は付きません。error.message をそのまま画面に出す設計にすると、クライアント側のエラー文が利用者に見える点を押さえておきましょう。画面には固定の文言と digest を出し、詳細はログに送るのが扱いやすい形です。

retry() で一時的なエラーから回復する

1回目は失敗し、2回目は成功するページで retry() を試しました(本番ビルド)。

// app/flaky/page.tsx
export const dynamic = 'force-dynamic'

let count = 0 // サーバープロセス内の呼び出し回数(検証用)

export default async function FlakyPage() {
  count++
  if (count % 2 === 1) {
    throw new Error(`一時的なエラー(${count}回目)`)
  }
  return <p id="ok">表示できました({count}回目)</p>
}
[flaky] first: 500 読み込みに失敗しました message: Minified React error #441; ...
[flaky] after retry: 表示できました(2回目)

retry() はサーバーにもう一度取りに行くので、Server Component のエラーから画面遷移なしで回復できました。

よくあるつまずき:イベントハンドラーのエラーは error.tsx に届かない

エラー境界が受け止めるのは描画中のエラーです。onClick の中や setTimeout などの非同期処理で起きたエラーは届きません。

エラーの場所 error.tsx に届くか 対処
Server Component の描画 届く そのまま
Client Component の描画 届く そのまま
onClick などのイベントハンドラー 届かない try/catch して useState に入れて表示
startTransition の中 届く(公式ドキュメントに記載) そのまま
ルートの layout.tsx error.tsx には届かない global-error.tsx

global-error.tsx:ルートレイアウトのエラーを受け止める

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

export default function GlobalError({
  error,
  retry,
}: {
  error: Error & { digest?: string }
  retry: () => void
}) {
  return (
    // ルートレイアウトの代わりになるので html と body が必要
    <html lang="ja">
      <body>
        <title>エラーが発生しました</title>
        <h2>アプリ全体でエラーが発生しました</h2>
        <p>エラーID: {error.digest ?? 'なし'}</p>
        <button onClick={() => retry()}>もう一度試す</button>
      </body>
    </html>
  )
}

global-error.tsx はルートレイアウトを置き換えるため、<html> と <body> を自分で書きます。グローバルCSSも読み込まれず、metadata も使えないので、スタイルと <title> はこのファイル内で用意します。

not-found.tsx と notFound():404を出す

コピペ用のコード

// app/items/[id]/page.tsx
import { notFound } from 'next/navigation'

const items: Record<string, string> = { '1': 'ノートPC', '2': 'モニター' }

export default async function ItemPage({
  params,
}: {
  params: Promise<{ id: string }>
}) {
  const { id } = await params
  const name = items[id]
  if (!name) {
    notFound() // 戻り値は never。return は不要
  }
  return <h1>備品: {name}</h1>
}
// app/items/[id]/not-found.tsx
export default function ItemNotFound() {
  return <h2>その備品は登録されていません</h2>
}

app/not-found.tsx も同じ形で置くと、どのURLにも一致しないときの画面になります。

実行結果(本番ビルド)

[notfound] /items/1  status= 200 robots= (なし)   | 備品: ノートPC
[notfound] /items/99 status= 404 robots= noindex | その備品は登録されていません
[notfound] /nothing  status= 404 robots= noindex | ページが見つかりません トップへ戻る

notFound() を呼ぶと、いちばん近い not-found.tsx が表示され、ステータス 404 と <meta name="robots" content="noindex"> が付きました。どのルートにも一致しない /nothing は、app/not-found.tsx が受け止めています。

loading.tsx と組み合わせると200になる点に注意

同じフォルダ(または親)に loading.tsx があると、応答はストリーミングで先に 200 を送り始めるため、その後の notFound() ではステータスを変えられません。公式ドキュメントでは、この場合も noindex が入るので検索結果には載らないと説明されています。正確な404ステータスが必要なら、ストリーミングが始まる前(loading.tsx の境界や await の前、または proxy.ts)で存在確認をします。

global-not-found.tsx(実験的)

app/(admin)/layout.tsx と app/(shop)/layout.tsx のようにルートレイアウトが複数ある場合は、共通の404を global-not-found.tsx で作れます。next.config.ts の experimental.globalNotFound: true が必要で、<html> と <body> を含む完全なHTMLを返します。

loading.tsx:読み込み中の画面とストリーミング

// app/slow/loading.tsx
export default function Loading() {
  return <p id="loading">読み込み中…</p>
}
// app/slow/page.tsx
export const dynamic = 'force-dynamic'

export default async function SlowPage() {
  await new Promise((r) => setTimeout(r, 2000))
  return <p id="done">2秒かかるデータを表示しました</p>
}

いつ何が届くかを時刻で確認

Node.js の fetch で応答のチャンク(小分けの塊)を for await で読み、届いた時刻と、どの文言を含むかを記録しました(本番ビルド、/slow)。

+186ms status=200
+190ms 1234bytes 読み込み中
+190ms 3401bytes 読み込み中
+2179ms 144bytes 2秒かかるデータ
+2180ms 1175bytes 2秒かかるデータ
+2181ms 14bytes

約0.2秒で「読み込み中…」を含むHTMLが届き、約2秒後に本文が同じ応答の続きとして届きました。これがストリーミングです。Playwright でブラウザ表示を測った結果も同じ傾向でした(本番:loading 表示 90ms、本文 2401ms。next dev は初回コンパイルが入り loading 1117ms、本文 3059ms)。

loading.tsx は「そのフォルダの page 全体を <Suspense> で包む」ファイルです。ページの一部だけを先に出したいときは、loading.tsx の代わりに自分で <Suspense fallback={...}> を置きます。

方法 範囲 向いている場面
loading.tsx ページ全体 画面遷移の直後にすぐ反応を返したい
<Suspense> 包んだ部分だけ 遅い部品だけ後から出したい(ヘッダーや見出しは先に出す)

forbidden.tsx / unauthorized.tsx:権限エラー(401・403)

forbidden() と unauthorized() は 16.3.6 時点でも実験的機能で、experimental.authInterrupts の設定が必要です(公式ドキュメントで確認)。

// next.config.ts
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  experimental: {
    authInterrupts: true,
  },
}

export default nextConfig
// app/admin/page.tsx
import { forbidden, unauthorized } from 'next/navigation'

export default async function AdminPage({
  searchParams,
}: {
  searchParams: Promise<{ role?: string }>
}) {
  const { role } = await searchParams // 検証用。実際はセッションから取得
  if (!role) unauthorized() // 未ログイン → 401
  if (role !== 'admin') forbidden() // 権限なし → 403
  return <h1>管理画面</h1>
}
// app/forbidden.tsx
export default function Forbidden() {
  return <h2>このページを見る権限がありません(403)</h2>
}

// app/unauthorized.tsx(別ファイル)
export default function Unauthorized() {
  return <h2>ログインが必要です(401)</h2>
}

実行結果(本番ビルド)

[auth] /admin              status= 401 robots= noindex | ログインが必要です(401)
[auth] /admin?role=member  status= 403 robots= noindex | このページを見る権限がありません(403)
[auth] /admin?role=admin   status= 200 robots= (なし)   | 管理画面

設定を忘れたときのエラー文

authInterrupts: false にしてビルドし直すと、ページは 500 になり、サーバーログに次の文が出ました。

⨯ Error: `forbidden()` is experimental and only allowed to be enabled when `experimental.authInterrupts` is enabled.
    at d (.next/server/chunks/ssr/_15plm3n._.js:1:198) {
  digest: '1137215759@E488'
}

このエラーが出たら、next.config.ts の experimental.authInterrupts を確認します。また、forbidden() / notFound() / redirect() は内部で特別な例外を投げて動くため、try/catch で囲むと止まってしまいます。囲む必要があるときは unstable_rethrow で再送出します。

catchError:画面の一部だけをエラー表示にする

catchError(next/error)は、ルートのフォルダ単位ではなく、任意のコンポーネントを包むエラー境界を作る関数です。ダッシュボードのように、1つの部品が失敗しても他の部品は表示したいときに使います。

コピペ用のコード(型エラーの対処込み)

公式ドキュメントの例は error.message をそのまま使っていますが、16.3.6 で型チェックすると次のエラーになりました。

app/widgets/error-boundary.tsx(9,27): error TS18046: 'error' is of type 'unknown'.
Failed to type check.

ErrorInfo の error は unknown 型として定義されています(node_modules/next/dist/client/components/error-boundary.d.ts で error: unknown を確認)。instanceof Error で絞り込んでから使います。

// app/widgets/error-boundary.tsx
'use client'

import { catchError, type ErrorInfo } from 'next/error'

function WidgetFallback(props: { title: string }, { error, retry }: ErrorInfo) {
  // ErrorInfo の error は unknown 型なので、絞り込んでから使う
  const message = error instanceof Error ? error.message : String(error)
  return (
    <section role="alert">
      <h3>{props.title}を表示できませんでした</h3>
      <p className="msg">{message}</p>
      <button onClick={() => retry()}>再読み込み</button>
    </section>
  )
}

export default catchError(WidgetFallback)
// app/widgets/page.tsx
import ErrorBoundary from './error-boundary'

export const dynamic = 'force-dynamic'

async function StockWidget() {
  throw new Error('在庫APIがタイムアウトしました')
  return <p>在庫: 12</p>
}

async function NewsWidget() {
  return <p id="news">お知らせ: 備品の棚卸しは月末です</p>
}

export default function WidgetsPage() {
  return (
    <main>
      <h1>ダッシュボード</h1>
      <ErrorBoundary title="在庫">
        <StockWidget />
      </ErrorBoundary>
      <ErrorBoundary title="お知らせ">
        <NewsWidget />
      </ErrorBoundary>
    </main>
  )
}

実行結果

# 本番(next dev では #441 の部分が「在庫APIがタイムアウトしました」)
[widgets] status= 500 | ダッシュボード 在庫を表示できませんでした Minified React error #441; ... 再読み込み お知らせ: 備品の棚卸しは月末です

在庫の部品だけがエラー表示になり、見出しとお知らせは表示されたままでした。本番ではここでもメッセージが #441 に置き換わるので、利用者向けの文言は props で渡す固定文にしておくと安心です。なお、この検証(ストリーミング境界なし)では、部品のエラーでもページ全体のステータスは 500 になりました。

比較 error.tsx catchError
範囲 ルートのフォルダ(ページ)単位 包んだコンポーネントだけ
置き方 ファイルを置く 関数で作ったコンポーネントで包む
props の受け渡し なし 第1引数で受け取れる(タイトル等)
notFound() / redirect() 通過させる 通過させる(誤って捕まえない)

Server Action のエラーは「戻り値で返す」

入力ミスや保存失敗のような想定内のエラーは、Server Action で throw せず、戻り値として返します。throw すると error.tsx の画面に切り替わり、入力中の内容が見えなくなるためです。公式ドキュメントも useActionState で戻り値を表示する形を案内しています。

コピペ用のコード

// app/form/actions.ts
'use server'

export type FormState = {
  ok: boolean
  message: string
  fieldErrors?: { title?: string }
  values?: { title: string }
}

export async function createTask(
  prevState: FormState,
  formData: FormData,
): Promise<FormState> {
  const title = String(formData.get('title') ?? '').trim()

  // 想定内のエラー → throw せず、戻り値で返す
  if (title.length < 3) {
    return {
      ok: false,
      message: '入力内容を確認してください',
      fieldErrors: { title: 'タスク名は3文字以上で入力してください' },
      values: { title },
    }
  }
  try {
    await saveTask(title)
  } catch {
    return { ok: false, message: '保存に失敗しました。時間をおいて再度お試しください', values: { title } }
  }
  return { ok: true, message: `「${title}」を登録しました` }
}

async function saveTask(title: string) {
  if (title.includes('NG')) throw new Error('DB write failed') // 検証用
}
// app/form/task-form.tsx
'use client'

import { useActionState } from 'react'
import { createTask, type FormState } from './actions'

const initialState: FormState = { ok: false, message: '' }

export function TaskForm() {
  const [state, formAction, pending] = useActionState(createTask, initialState)

  return (
    <form action={formAction}>
      <input name="title" defaultValue={state.values?.title} />
      {state.fieldErrors?.title && <p id="field-error">{state.fieldErrors.title}</p>}
      <button disabled={pending}>登録</button>
      {state.message && <p id="message" aria-live="polite">{state.message}</p>}
    </form>
  )
}

実行結果(本番ビルド、Playwright で入力して送信)

[form] "ab"          → message= 入力内容を確認してください | field= タスク名は3文字以上で入力してください | input= ab
[form] "NGな備品"     → message= 保存に失敗しました。時間をおいて再度お試しください | field= null | input= NGな備品
[form] "備品の棚卸し" → message= 「備品の棚卸し」を登録しました | field= null | input=

入力エラーと保存失敗では入力欄の値が残り、成功時だけ空になりました。React 19 のフォームは送信後に入力欄がリセットされるため、エラー時は values を戻り値に含めて defaultValue に戻すのがポイントです。

エラーの種類 例 返し方
入力の検証エラー 文字数不足、形式違い 戻り値(fieldErrors)
業務上のエラー 在庫切れ、重複登録 戻り値(message)
外部の一時的な失敗 保存失敗、タイムアウト try/catch して戻り値
権限がない 他人のデータを更新 forbidden() / unauthorized()
バグ・想定外 null 参照など throw のまま(error.tsx へ)

エラーハンドリング実装のチェックリスト

チェック項目 確認
app/error.tsx と app/global-error.tsx を置いた □
エラー画面に digest を「エラーID」として表示している □
再試行は reset ではなく retry を使っている □
存在しないデータは notFound() で404にしている □
404ステータスが必要なページで、notFound() をストリーミング開始前に呼んでいる □
Server Action の想定内エラーは戻り値で返し、入力値を戻している □
forbidden() を使うなら authInterrupts を設定している □

よくある質問

Q. error.tsx に 'use client' を書かないとどうなりますか? A. エラー境界はクライアントコンポーネントである必要があるため、must be a Client Component というエラーでビルドが止まります(本文で実測)。error.tsx と global-error.tsx の1行目には必ず 'use client' を書きます。一方、not-found.tsx・loading.tsx・forbidden.tsx はサーバーコンポーネントのままで使えます。

Q. 本番でエラーの内容がわからないときはどう調べますか? A. 画面に表示された digest でサーバーログを検索します。今回の検証でも、画面の digest: 3036563320 とサーバーログの digest: '3036563320' が一致し、ログ側に元のメッセージとスタックが出ていました。

Q. reset と retry はどちらを使えばよいですか? A. 16.3 以降は retry が推奨です。retry はサーバーから取り直して再描画するので、Server Component のエラーから回復できます。reset は再取得しないため、クライアント側の状態だけを戻したいときに使います。

Q. notFound() を呼んだのにステータスが200になるのはなぜですか? A. 同じ階層か親に loading.tsx や <Suspense> があり、ストリーミングが先に始まっているためです。noindex は自動で付きます。404ステータスが必要なら、ストリーミング前に存在確認をするか、proxy.ts で判定します。

まとめ

  • 想定内のエラーは戻り値、想定外のエラーは throw して error.tsx の2段構えで考える
  • 本番では Server Component のエラー文が #441 に置き換わり、digest だけが届く。digest でサーバーログを検索できる
  • 16.3 で retry と catchError が安定版。再試行は reset より retry を使う
  • catchError の error は unknown 型なので instanceof Error で絞り込む
  • notFound() は 404 + noindex。loading.tsx があると 200 で始まる点に注意
  • forbidden() / unauthorized() は experimental.authInterrupts が必要(実測で 403 / 401)
  • loading.tsx は約0.2秒で先に届き、本文は同じ応答の続きとしてストリーミングされる

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Next.jsのエラーハンドリング|error.tsxとnot-foundを実測」

株式会社RJCでは、エラーが起きたときに利用者が迷わない画面づくりを、プロジェクトの中で仲間と工夫し合っています。こうした知識を共有しながら、日々技術を磨いています。

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?