- Next.js
Next.jsのエラーハンドリング|error.tsxとnot-foundを実測
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期連続の黒字決算。