Next.js Error Handling
โฑ๏ธ ~4-minute bite ยท solve the sandbox to master
5-Year-Old Metaphor
โ The physical, real-world picture. No jargon.๐ฅ error.tsx = fire doors per floor. ๐ not-found.tsx = lost & found desk. ๐จ global-error.tsx = building evacuation.
The fire safety analogy
error.tsx (fire doors per floor): each floor has its own fire door that contains smoke to that floor. A fire on the 3rd floor doesn't evacuate the whole building โ only the 3rd floor. Each route segment can have its own error.tsx, containing failures to that section.
not-found.tsx (lost & found desk): when something isn't where it's supposed to be, you go to lost & found โ a specific, calm place for missing items. not-found.tsx is the polite "this doesn't exist" UI, not an error state.
global-error.tsx (building evacuation): when the fire is in the building's core โ the root layout โ you evacuate everyone. global-error.tsx replaces the entire app with a last-resort UI. It's the alarm that fires when everything else fails.
Error boundary hierarchy
| 1 | ย |
| 2 | app/ |
| 3 | โโโ global-error.tsx โ catches errors in layout.tsx (root) |
| 4 | โโโ layout.tsx โ ROOT: global-error.tsx catches this |
| 5 | โโโ not-found.tsx โ catches notFound() anywhere in app |
| 6 | โ |
| 7 | โโโ dashboard/ |
| 8 | โ โโโ error.tsx โ catches render errors in dashboard segment |
| 9 | โ โโโ layout.tsx โ if THIS throws โ parent error.tsx catches it |
| 10 | โ โโโ page.tsx โ if THIS throws โ dashboard/error.tsx catches it |
| 11 | โ |
| 12 | โโโ blog/ |
| 13 | โโโ error.tsx โ catches render errors in blog segment |
| 14 | โโโ not-found.tsx โ catches notFound() inside /blog/** |
| 15 | โโโ [slug]/ |
| 16 | โโโ page.tsx โ notFound() โ blog/not-found.tsx |
| 17 | โ throw Error โ blog/error.tsx |
| 18 | ย |
| 19 | Key rule: error.tsx catches errors from its CHILDREN, not from |
| 20 | layout.tsx at the SAME level. To catch layout errors, put |
| 21 | error.tsx in the PARENT directory. |
Render errors (use error.tsx)
- โข throw in a Server Component
- โข Async component Promise rejection
- โข Errors in child components during render
- NOT: event handler errors
Expected errors (return, don't throw)
- โข Validation failures in Server Actions
- โข DB record not found (could use notFound())
- โข Network timeouts in Server Actions
- Return { error: string } instead
The most important rule
error.tsx does NOT catch errors in layout.tsx at the same level. A dashboard/error.tsx catches errors in dashboard/page.tsx, but NOT in dashboard/layout.tsx. To catch layout errors, place error.tsx in the parent directory (or use global-error.tsx for the root).
Interactive Sandbox
โ Move something, see it react instantly.global-error.tsx
Catches root layout errors
app/error.tsx
Catches app-level segment errors
not-found.tsx
Catches notFound() calls
app/dashboard/error.tsx
Catches dashboard segment errors
reset() handler
Retries the failed boundary
Server Action try/catch
Returns { error } โ no boundary involved
Challenge
Which file catches an error thrown inside the root layout.tsx?
Why Should I Care?
โ The exact interview question + the bug it kills.Interview Q&A
Q: Why use error.tsx instead of try/catch in components?
A: error.tsx is a React Error Boundary โ it catches rendering errors thrown during the render phase, which try/catch can't. For async errors in event handlers, use try/catch. For render errors, use error.tsx.
| 1 | // โ Can't catch render-phase errors with try/catch: |
| 2 | function MyComponent() { |
| 3 | try { |
| 4 | return <UserProfile />; // if UserProfile throws during render, |
| 5 | // the throw propagates PAST this try/catch |
| 6 | } catch (e) { |
| 7 | // This won't catch render errors โ React's fiber reconciler |
| 8 | // propagates render errors up through Error Boundaries, not |
| 9 | // through the normal JS call stack try/catch |
| 10 | } |
| 11 | } |
| 12 | ย |
| 13 | // โ error.tsx implements an Error Boundary correctly: |
| 14 | // React catches the thrown value during reconciliation |
| 15 | // and renders the error.tsx fallback instead |
| 16 | ย |
| 17 | // โ DO use try/catch for event handlers (not render errors): |
| 18 | function MyButton() { |
| 19 | async function handleClick() { |
| 20 | try { |
| 21 | await someAsyncOperation(); // event handler โ try/catch works |
| 22 | } catch (e) { |
| 23 | setError(e.message); // store in state to display |
| 24 | } |
| 25 | } |
| 26 | return <button onClick={handleClick}>Click</button>; |
| 27 | } |
Q: When does not-found.tsx render?
A: When you call notFound() from any Server Component or Route Handler in a segment, or when Next.js can't match the URL to any route. It renders the nearest not-found.tsx up the tree.
| 1 | // Two triggers for not-found.tsx: |
| 2 | ย |
| 3 | // 1. Explicit: calling notFound() in your code |
| 4 | import { notFound } from "next/navigation"; |
| 5 | ย |
| 6 | export default async function ProductPage({ params }) { |
| 7 | const product = await db.product.findById(params.id); |
| 8 | if (!product) notFound(); // โ renders nearest not-found.tsx |
| 9 | return <Product data={product} />; |
| 10 | } |
| 11 | ย |
| 12 | // 2. Implicit: Next.js can't match the URL to any route |
| 13 | // User visits /products/abc/xyz/nonexistent |
| 14 | // โ no matching page.tsx anywhere in the route tree |
| 15 | // โ Next.js calls notFound() internally |
| 16 | // โ renders app/not-found.tsx (root level) |
| 17 | ย |
| 18 | // Nearest not-found.tsx wins: |
| 19 | // /products/123 โ not found โ checks: |
| 20 | // app/products/[id]/not-found.tsx (if exists) |
| 21 | // app/products/not-found.tsx (if exists) |
| 22 | // app/not-found.tsx (root fallback) |
| 23 | // First one found up the tree is rendered |
Q: Why must global-error.tsx be a Client Component?
A: It replaces the entire root layout, including the html and body tags โ it must render the full document. Since it needs to call reset(), and reset is a function prop, it requires 'use client'.
| 1 | // global-error.tsx MUST be "use client" because: |
| 2 | // 1. React Error Boundaries are a client-side React feature |
| 3 | // (Error Boundaries don't exist in RSC rendering) |
| 4 | // 2. It receives a 'reset' function prop โ functions can only |
| 5 | // be passed to Client Components |
| 6 | // 3. It renders <html> and <body> โ it's a full document replacement |
| 7 | ย |
| 8 | // global-error.tsx โ correct |
| 9 | "use client"; // REQUIRED โ not optional |
| 10 | ย |
| 11 | export default function GlobalError({ |
| 12 | error, |
| 13 | reset, |
| 14 | }: { |
| 15 | error: Error & { digest?: string }; |
| 16 | reset: () => void; // โ function prop โ must be Client Component |
| 17 | }) { |
| 18 | return ( |
| 19 | <html> {/* Must render html+body โ root layout is gone */} |
| 20 | <body> |
| 21 | <button onClick={reset}>Try again</button> |
| 22 | </body> |
| 23 | </html> |
| 24 | ); |
| 25 | } |
| 26 | ย |
| 27 | // Without "use client" โ build error: |
| 28 | // Error: global-error.tsx must be a Client Component |
The Deep Dive
โ Spec refs, engine internals, the minutiae.error.tsx props: error.digest and error serialization
The error prop received by error.tsx is not the original server error. Next.js serializes errors from Server Components before sending them to the client. In production, only the message is sent โ the stack trace is stripped. The digest is a short hash that links the client-side error to the full server-side stack trace in your logs.
| 1 | // Server Component (runs on server): |
| 2 | export default async function ServerPage() { |
| 3 | throw new Error("DB connection failed: PostgreSQL ECONNREFUSED"); |
| 4 | } |
| 5 | ย |
| 6 | // What error.tsx receives in PRODUCTION: |
| 7 | { |
| 8 | message: "An error occurred in the Server Components render.", |
| 9 | // ^ Generic message โ real message NOT sent to client for security |
| 10 | digest: "a1b2c3d4", |
| 11 | // ^ Hash you can grep in your server logs to find the real error |
| 12 | } |
| 13 | ย |
| 14 | // What error.tsx receives in DEVELOPMENT: |
| 15 | { |
| 16 | message: "DB connection failed: PostgreSQL ECONNREFUSED", |
| 17 | // ^ Full message โ dev mode sends the real error for debugging |
| 18 | stack: "Error: DB connection failed\n at ServerPage ...", |
| 19 | digest: "a1b2c3d4", |
| 20 | } |
| 21 | ย |
| 22 | // Best practice: log digest to your error tracker |
| 23 | useEffect(() => { |
| 24 | if (error.digest) { |
| 25 | Sentry.captureException(error, { tags: { digest: error.digest } }); |
| 26 | } |
| 27 | }, [error]); |
| 28 | // โ Server logs show: "Error digest: a1b2c3d4" with full stack |
Suspense + Error Boundary interaction
When an async Server Component inside a <Suspense> boundary throws, the error propagates up through the Suspense boundary to the nearest Error Boundary (error.tsx). The fallback prop of Suspense is only shown during loading โ on error, it's the Error Boundary that takes over.
| 1 | // Layout structure: |
| 2 | // app/dashboard/ |
| 3 | // error.tsx โ Error Boundary wraps this segment |
| 4 | // layout.tsx |
| 5 | // page.tsx |
| 6 | ย |
| 7 | // page.tsx: |
| 8 | import { Suspense } from "react"; |
| 9 | ย |
| 10 | export default function DashboardPage() { |
| 11 | return ( |
| 12 | <div> |
| 13 | <StaticHeader /> |
| 14 | <Suspense fallback={<Spinner />}> |
| 15 | {/* AsyncWidget suspends (shows Spinner) while loading */} |
| 16 | {/* If AsyncWidget THROWS, Suspense does NOT catch it */} |
| 17 | {/* The throw propagates to error.tsx */} |
| 18 | <AsyncWidget /> |
| 19 | </Suspense> |
| 20 | </div> |
| 21 | ); |
| 22 | } |
| 23 | ย |
| 24 | async function AsyncWidget() { |
| 25 | const data = await fetchData(); // Promise rejection โ throws |
| 26 | return <Widget data={data} />; |
| 27 | } |
| 28 | ย |
| 29 | // Error flow: |
| 30 | // 1. AsyncWidget starts rendering โ suspends (Spinner shows) |
| 31 | // 2. fetchData() rejects โ AsyncWidget throws |
| 32 | // 3. Suspense does NOT catch throws โ only suspends (pending state) |
| 33 | // 4. Error propagates to nearest Error Boundary โ error.tsx renders |
Custom error classes and structured error handling
| 1 | // lib/errors.ts โ structured error types |
| 2 | export class AppError extends Error { |
| 3 | constructor( |
| 4 | message: string, |
| 5 | public code: string, |
| 6 | public statusCode: number = 500 |
| 7 | ) { |
| 8 | super(message); |
| 9 | this.name = "AppError"; |
| 10 | } |
| 11 | } |
| 12 | ย |
| 13 | export class ValidationError extends AppError { |
| 14 | constructor(message: string, public fields: Record<string, string[]>) { |
| 15 | super(message, "VALIDATION_ERROR", 422); |
| 16 | this.name = "ValidationError"; |
| 17 | } |
| 18 | } |
| 19 | ย |
| 20 | export class NotFoundError extends AppError { |
| 21 | constructor(resource: string) { |
| 22 | super(`${resource} not found`, "NOT_FOUND", 404); |
| 23 | this.name = "NotFoundError"; |
| 24 | } |
| 25 | } |
| 26 | ย |
| 27 | // Server Component โ throw typed errors |
| 28 | import { NotFoundError } from "@/lib/errors"; |
| 29 | ย |
| 30 | export default async function ProductPage({ params }) { |
| 31 | const product = await db.product.findById(params.id); |
| 32 | if (!product) throw new NotFoundError("Product"); |
| 33 | return <Product data={product} />; |
| 34 | } |
| 35 | ย |
| 36 | // error.tsx โ handle typed errors |
| 37 | "use client"; |
| 38 | export default function ProductError({ error, reset }) { |
| 39 | // In dev, you can check error.name โ in prod, use error.digest |
| 40 | const isNotFound = error.message?.includes("not found"); |
| 41 | return ( |
| 42 | <div> |
| 43 | {isNotFound ? ( |
| 44 | <p>This product doesn't exist. <a href="/products">Browse all</a></p> |
| 45 | ) : ( |
| 46 | <button onClick={reset}>Try again</button> |
| 47 | )} |
| 48 | </div> |
| 49 | ); |
| 50 | } |
Using error.digest for server-side logging correlation
Next.js generates a digest for each Server Component error. This digest appears in both the server log and the client error prop, letting you correlate client reports with server stack traces.
| 1 | // Server-side: Next.js logs the error with its digest |
| 2 | // Console output (server): |
| 3 | // Error: DB connection failed [digest: "a1b2c3d4"] |
| 4 | // at ProductPage (app/products/[id]/page.tsx:12:7) |
| 5 | // at async ... |
| 6 | ย |
| 7 | // Client-side: error.tsx receives the digest |
| 8 | "use client"; |
| 9 | export default function ProductError({ error, reset }) { |
| 10 | // Log to client-side error tracking |
| 11 | // Include digest so support can find the server log |
| 12 | useEffect(() => { |
| 13 | fetch("/api/log-error", { |
| 14 | method: "POST", |
| 15 | body: JSON.stringify({ |
| 16 | digest: error.digest, |
| 17 | userAgent: navigator.userAgent, |
| 18 | url: window.location.href, |
| 19 | }), |
| 20 | }); |
| 21 | }, [error]); |
| 22 | ย |
| 23 | return ( |
| 24 | <div> |
| 25 | <p>Something went wrong.</p> |
| 26 | {error.digest && ( |
| 27 | <p className="text-xs text-gray-400"> |
| 28 | Reference: {error.digest} |
| 29 | {/* User can give this to support โ support greps server logs */} |
| 30 | </p> |
| 31 | )} |
| 32 | <button onClick={reset}>Try again</button> |
| 33 | </div> |
| 34 | ); |
| 35 | } |
Interview Questions
โ Real questions from real interviews โ with answers.It doesn't catch: layout.tsx errors at the same level, event handler errors, or Server Action throws.
React Error Boundaries require the client runtime โ they're class-based and receive a reset() function prop.
A server-generated hash that links the client-visible error to the full server-side stack trace in your logs.
reset() alone for transient errors; router.refresh() first for data errors so the retry gets fresh server data.
Suspense only handles pending state โ errors thrown inside Suspense propagate up to the nearest Error Boundary (error.tsx).
notFound() is a signal, not an error โ it renders not-found.tsx with a 404 status without triggering error.tsx.
Memory Game
โ Quick quiz โ lock the concept in long-term memory.What is the correct approach to show per-field validation errors from a Server Action in a form?