NOTE · 2026-05-07
Next.js 16 のエラーページ三層を別物として扱う
Next.js 16 App Router の not-found / error / global-error は、Server/Client 境界も root layout 依存可否もテスト容易性も全部違う。一枚岩で書こうとして詰まったので、三層を別物として扱う前提で整理し直した。global-error は孤島で、layout の便利機能が一切効かない。
Next.js 16 App Router でエラーページを真面目に揃えようとして、error.tsx 1 枚で済むと思って書き始めたら詰まった。ドキュメントを読み直すと 三層 ある。役割も実装制約もテスト手法も全部違うので、別物として扱う前提で書き直したら綺麗に収まった。三層を一枚岩で書こうとすると破綻するのは、各層が前提とする「root layout が生きているか」が違うからだった。
三層の役割
| ファイル | 起動条件 | Component | root layout 依存 | テスト手段 |
|---|---|---|---|---|
app/not-found.tsx | notFound() 呼び出し or 未定義ルート | Server | OK | 適当な未定義 URL を踏む |
app/error.tsx | segment 内で throw された runtime error | Client | OK (root layout 配下で render) | dev 限定 throw ルート |
app/global-error.tsx | root layout 自体で throw された error | Client | NG (layout を replace する) | 実用的に再現不能 |
app/error.tsx は segment ごとに置けるが、root に置けば catchall として機能する。app/global-error.tsx は root layout を入れ替える ため、その中では <html> と <body> を自前で書き、font / palette / globals.css に頼らない。これが一番ハマった点で、後述する。
app/not-found.tsx (Server Component)
Server Component なので notFound() 呼び出し時の HTTP status が自然に 404 になる。root layout の font / palette / グローバル CSS をそのまま使えるので、UI は普通のページと同じノリで書ける。ただし state や client hook は使えないので、リトライボタンのような対話 UI は置けない (代わりに <Link> で LP に戻す導線にする)。
// app/not-found.tsx
import Link from "next/link";
export default function NotFound() {
return (
<main>
<h1>404</h1>
<p>このページは見つかりませんでした。</p>
<Link href="/">トップへ戻る</Link>
</main>
);
}
app/error.tsx (Client Component)
"use client" 必須。Next.js 16.2 から prop が拡張されている。
"use client";
interface RetryFn {
(): void;
}
export default function Error({
error,
reset,
unstable_retry,
}: {
error: Error & { digest?: string };
reset: () => void;
unstable_retry: RetryFn;
}) {
// ...
}
reset() は同じ tree を再 mount するだけで、server payload は再 fetch しない。unstable_retry() は server からの再 fetch + 再 render を伴う。データ起因のエラー (DB 一時障害、外部 API timeout) はこちらで叩き直す方が筋がいい。
root layout 配下で render されるので、fonts / palette / globals.css は普通に使える。error.digest は production build で stable な ID で、本番で Sentry 等に送れば user-facing error と server log を突合できる。
TS plugin 71007 の誤警告と回避
Next.js 16.2 の TS plugin (rules/client-boundary.ts) は client component の prop が serializable かを検査するが、whitelist が reset と *Action で止まっている。unstable_retry を () => void で 直接 typed すると 71007 (non-serializable prop) として警告が出る。
回避は interface 経由で型を宣言 すること:
interface RetryFn {
(): void;
}
// ...
unstable_retry: RetryFn;
getTypeAtLocation で symbol が InterfaceDeclaration に解決されると check を潜り抜ける。type RetryFn = () => void の type alias で同じ効果が出るかは未検証。Next 16.3 以降で whitelist が広がれば不要になる。
app/global-error.tsx (Client Component、孤島)
ここが一番ハマる。root layout 自体で throw されたエラー時に起動するので、自身が <html> <body> を含む完全な HTML ツリーを返す必要がある。
つまり:
next/font/googleで読んだ font は使えない (root layout が動いていないので import されていない)- CSS variables も効かない (root layout の
<body>で定義しているので) globals.cssの reset も効かない (link されていない)
ボタン色や font-family を含む全 style を inline で書く しかない。
"use client";
export default function GlobalError({ error }: { error: Error }) {
return (
<html lang="ja">
<body
style={{
margin: 0,
fontFamily: "system-ui, -apple-system, sans-serif",
background: "#fffbe6",
color: "#222",
minHeight: "100vh",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<div style={{ textAlign: "center" }}>
<h1 style={{ fontSize: "2rem", marginBottom: "0.5rem" }}>
予期しないエラーが発生しました
</h1>
<a
href="/"
style={{
display: "inline-block",
marginTop: "1rem",
padding: "0.5rem 1rem",
border: "1px solid #222",
borderRadius: "0.25rem",
color: "#222",
textDecoration: "none",
}}
>
トップへ戻る
</a>
</div>
</body>
</html>
);
}
<Link> は使えるが、href="/" の <a> で十分なケースが多い (どうせ root layout から立ち上げ直しになる)。
dev overlay の罠
Next.js dev mode は error.tsx の上に dev error overlay を被せて表示する。独自 UI を確認するには 2 通り:
- dev で発火 → Esc で overlay を dismiss → 裏に独自
error.tsxが見える - または
pnpm exec next startで production build を起動 → overlay 無し
私は (1) だけで済ませてレビューに出して、production build で見たら CSS variables が効いていなかった、という事故を 1 回踏んだ。global-error.tsx は特に dev overlay が前面に来やすいので、production build 起動を 1 回挟む癖をつけると安心。
production に throw ルートを残さない
エラーページの確認用に /throw のような route を残すと攻撃面になる。dev 専用ガードで隔離する:
// app/dev/throw/page.tsx
import { notFound } from "next/navigation";
export default function DevThrow() {
if (process.env.NODE_ENV === "production") notFound();
throw new Error("dev throw");
}
production では notFound() で消えるので、attack surface にならない。/dev/inbox のような他の dev-only ルートも同じガードで隔離するのが揃っていてレビューしやすい。
E2E テスト戦略
not-found.tsxは HTTP 404 ステータス + 独自 UI を assert できる。smoke test として実装容易error.tsxは再現が難しい。Playwright から/dev/throwを踏ませる手があるが、production build で消えるので CI で使えるのは preview 環境などに限るglobal-error.tsxは実質テスト不能。レビューで目視確認するしかない
なので CI で確実に守れるのは not-found だけ、と割り切る。残りは production build を 1 回見る手作業に倒す。
設計のキモ
- 三層を別物として扱う。not-found は Server、error/global-error は Client、root layout への依存度が違う
- global-error は孤島。root layout の便利機能を一切使えない前提で、styling を inline で完結させる
- dev overlay と production build の差 を意識し、production build で 1 回見る
- production に throw ルートを残さない。
process.env.NODE_ENV === "production" → notFound()で隔離 - TS plugin 71007 は interface で回避。type alias でも回避できる可能性あり (未検証)
(Origin: kokan-nikki PR #76、commit 30b89c2。三層 + e2e smoke test を一括投入した実装。)