miya8060
← back to blog

NOTE · 2026-05-07

Next.js 16 のエラーページ三層を別物として扱う

Next.js 16 App Router の not-found / error / global-error は、Server/Client 境界も root layout 依存可否もテスト容易性も全部違う。一枚岩で書こうとして詰まったので、三層を別物として扱う前提で整理し直した。global-error は孤島で、layout の便利機能が一切効かない。

nextjsapp-routererror-handlingfrontend

Next.js 16 App Router でエラーページを真面目に揃えようとして、error.tsx 1 枚で済むと思って書き始めたら詰まった。ドキュメントを読み直すと 三層 ある。役割も実装制約もテスト手法も全部違うので、別物として扱う前提で書き直したら綺麗に収まった。三層を一枚岩で書こうとすると破綻するのは、各層が前提とする「root layout が生きているか」が違うからだった。

三層の役割

ファイル起動条件Componentroot layout 依存テスト手段
app/not-found.tsxnotFound() 呼び出し or 未定義ルートServerOK適当な未定義 URL を踏む
app/error.tsxsegment 内で throw された runtime errorClientOK (root layout 配下で render)dev 限定 throw ルート
app/global-error.tsxroot layout 自体で throw された errorClientNG (layout を replace する)実用的に再現不能

app/error.tsx は segment ごとに置けるが、root に置けば catchall として機能する。app/global-error.tsxroot 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 通り:

  1. dev で発火 → Esc で overlay を dismiss → 裏に独自 error.tsx が見える
  2. または 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.tsxHTTP 404 ステータス + 独自 UI を assert できる。smoke test として実装容易
  • error.tsx は再現が難しい。Playwright から /dev/throw を踏ませる手があるが、production build で消えるので CI で使えるのは preview 環境などに限る
  • global-error.tsx は実質テスト不能。レビューで目視確認するしかない

なので CI で確実に守れるのは not-found だけ、と割り切る。残りは production build を 1 回見る手作業に倒す。

設計のキモ

  1. 三層を別物として扱う。not-found は Server、error/global-error は Client、root layout への依存度が違う
  2. global-error は孤島。root layout の便利機能を一切使えない前提で、styling を inline で完結させる
  3. dev overlay と production build の差 を意識し、production build で 1 回見る
  4. production に throw ルートを残さないprocess.env.NODE_ENV === "production" → notFound() で隔離
  5. TS plugin 71007 は interface で回避。type alias でも回避できる可能性あり (未検証)

(Origin: kokan-nikki PR #76、commit 30b89c2。三層 + e2e smoke test を一括投入した実装。)