Next.js 10 ▲ Error Handling — error.js boundaries & uncaught exception strategies
Next.js App Router treats errors as a structural concern. An error.js file placed in a route segment becomes a React Error Boundary that catches rendering errors in that segment and everything below it, showing a fallback UI instead of crashing the whole page. This is the mechanism for uncaught exceptions: bugs that should never happen, that indicate a problem in the code rather than a normal failure like a missing record or a failed validation. The boundary contains the blast radius, the fallback gives the user a way to recover, and the server-side error is logged with a digest hash that matches the client-facing message to the full stack trace in the logs.
This chapter covers the error.js file convention, the global-error.js boundary for root layout failures, the not-found.js file for expected 404s, and the strategies for handling errors that occur outside the rendering cycle—event handlers, async callbacks, and Server Actions. It also covers the production behavior where server error messages are replaced with a digest to prevent information leakage.
Key point: error.js catches rendering errors in its segment and below, but it does not catch errors in event handlers, async code after render, or the layout.js above it. Those require manual handling, global-error.js, or try/catch in Server Actions.
Why error boundaries exist in the App Router
The blast radius problem. Before error boundaries, a single unhandled throw in a component unmounted the entire React tree. One broken widget took down the whole page. error.js contains the failure: the boundary wraps the segment’s page and nested layouts, and when a render throws, the boundary’s fallback replaces only that subtree. The navigation and the rest of the page remain intact .
The production information leakage problem. A server-side error can contain database connection strings, internal file paths, or sensitive query fragments. Next.js strips the message from server errors in production and replaces it with a generic message plus a digest hash. The full error is logged on the server, and the digest is the key that connects the client-facing fallback to the server-side log entry . This is deliberate: the client never sees the raw server error.
The expected-versus-unexpected distinction. Next.js separates errors into two categories. Expected errors are normal failures—validation errors, failed fetches, missing records—and should be handled with conditional rendering or notFound(). Uncaught exceptions are bugs, and they should be thrown so that the nearest error boundary catches them . The error.js file is for the second category. The not-found.js file is for the first.
a. The error.js file and its props
An error.js file must be a Client Component. React Error Boundaries rely on class component lifecycle methods (getDerivedStateFromError, componentDidCatch) that only execute in the browser, so the file must start with 'use client' .
// app/dashboard/error.tsx
'use client'
import { useEffect } from 'react'
export default function Error({
error,
retry,
}: {
error: Error & { digest?: string }
retry: () => void
}) {
useEffect(() => {
console.error(error)
}, [error])
return (
<div>
<h2>Something went wrong</h2>
<button onClick={() => retry()}>Try again</button>
</div>
)
}
The component receives two props. error is the thrown value, forwarded from the server or the client. In production, server errors arrive with a generic message and a digest hash; client errors retain their original message . retry is a function that attempts to re-fetch and re-render the boundary’s children. If the error was transient—a network blip, a temporarily unavailable service—retry() may recover the page without a full reload . The older reset prop is still available for clearing the error state without re-fetching, but the documentation recommends retry in most cases .
The boundary wraps loading.js, not-found.js, page.js, and nested layout.js files in its segment. It does not wrap the layout.js or template.js above it. An error.js at app/dashboard/error.tsx catches errors from app/dashboard/page.tsx and everything nested under /dashboard, but not from app/dashboard/layout.tsx .
b. What error boundaries do not catch
Error boundaries catch errors during rendering. They do not catch errors in event handlers, asynchronous code that runs after render, or notFound() calls.
// NOT caught by error.js
function Button() {
const handleClick = () => {
throw new Error('This crashes the event handler, not the boundary')
}
return <button onClick={handleClick}>Click</button>
}
Event handler errors must be caught manually with try/catch and stored in state. The component then renders an error message based on that state .
'use client'
import { useState } from 'react'
export function Button() {
const [error, setError] = useState<Error | null>(null)
const handleClick = () => {
try {
riskyOperation()
} catch (e) {
setError(e as Error)
}
}
if (error) return <p>Operation failed: {error.message}</p>
return <button onClick={handleClick}>Click</button>
}
notFound() is also not an error in the boundary sense. It throws a special control-flow error that Next.js catches and routes to the nearest not-found.js file. An error.js boundary will not intercept it .
c. global-error.js for root layout failures
The root layout is above every error.js boundary. If the root layout itself throws, no segment-level boundary can catch it. global-error.js exists for this case. It lives at app/global-error.tsx and replaces the root layout when active, which means it must render its own <html> and <body> tags .
// app/global-error.tsx
'use client'
export default function GlobalError({
error,
retry,
}: {
error: Error & { digest?: string }
retry: () => void
}) {
return (
<html>
<body>
<h1>Something went wrong</h1>
<button onClick={() => retry()}>Try again</button>
</body>
</html>
)
}
Because global-error.js replaces the root layout, it does not inherit the root layout’s styles, fonts, or providers. Any styling it needs must be defined within the component itself . It also cannot export metadata or generateMetadata, because those are server-only exports and the file is a Client Component. Use the React <title> component instead .
d. Server Actions and try/catch
Server Actions run outside the rendering cycle. An uncaught exception in a Server Action does not trigger the nearest error.js during the action itself; it triggers the boundary when the action’s failure causes a re-render or a redirect that throws. The recommended pattern is to catch errors inside the action and return a structured result .
'use server'
export async function deleteInvoice(id: string) {
try {
await db.invoice.delete({ where: { id } })
revalidatePath('/dashboard/invoices')
return { success: true }
} catch (e) {
return { error: 'Failed to delete invoice' }
}
}
The redirect call must be outside the try/catch. redirect works by throwing a special error that Next.js intercepts, and a catch block would swallow it .
Complete Example Session
// ============================================
// PART 1: THE BASIC ERROR BOUNDARY
// ============================================
// app/dashboard/error.tsx
'use client'
import { useEffect } from 'react'
export default function Error({
error,
retry,
}: {
error: Error & { digest?: string }
retry: () => void
}) {
useEffect(() => {
console.error(error)
}, [error])
return (
<div>
<h2>Something went wrong</h2>
<button onClick={() => retry()}>Try again</button>
</div>
)
}
// ============================================
// PART 2: WHAT THE BOUNDARY CATCHES
// ============================================
// app/dashboard/page.tsx
export default async function Page() {
const data = await fetchData()
return <Dashboard data={data} />
}
// If fetchData throws, app/dashboard/error.tsx renders.
// If a component inside Dashboard throws during render,
// the same boundary catches it.
// ============================================
// PART 3: WHAT IT DOES NOT CATCH
// ============================================
// app/dashboard/client-button.tsx
'use client'
export function ClientButton() {
const handleClick = () => {
throw new Error('Not caught by error.js')
}
return <button onClick={handleClick}>Click</button>
}
// The throw happens in an event handler.
// The error boundary does not see it.
// ============================================
// PART 4: MANUAL HANDLING FOR EVENT ERRORS
// ============================================
'use client'
import { useState } from 'react'
export function ClientButton() {
const [error, setError] = useState<string | null>(null)
const handleClick = () => {
try {
riskyOperation()
} catch (e) {
setError('Operation failed')
}
}
if (error) return <p>{error}</p>
return <button onClick={handleClick}>Click</button>
}
// ============================================
// PART 5: GLOBAL ERROR FOR ROOT LAYOUT
// ============================================
// app/global-error.tsx
'use client'
export default function GlobalError({
error,
retry,
}: {
error: Error & { digest?: string }
retry: () => void
}) {
return (
<html>
<body>
<h1>Application error</h1>
<p>Something went wrong at the root.</p>
<button onClick={() => retry()}>Reload</button>
</body>
</html>
)
}
// ============================================
// PART 6: NOT-FOUND FOR EXPECTED 404s
// ============================================
// app/posts/[id]/page.tsx
import { notFound } from 'next/navigation'
export default async function PostPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const post = await getPost(id)
if (!post) {
notFound() // renders the nearest not-found.js
}
return <article>{post.content}</article>
}
// ============================================
// PART 7: NOT-FOUND BOUNDARY
// ============================================
// app/posts/[id]/not-found.tsx
export default function NotFound() {
return (
<div>
<h2>Post not found</h2>
<p>The post you are looking for does not exist.</p>
</div>
)
}
// ============================================
// PART 8: SERVER ACTION ERROR HANDLING
// ============================================
// app/actions.ts
'use server'
import { revalidatePath } from 'next/cache'
export async function deletePost(id: string) {
try {
await db.post.delete({ where: { id } })
revalidatePath('/posts')
return { success: true }
} catch (e) {
return { error: 'Failed to delete post' }
}
}
// ============================================
// PART 9: REDIRECT OUTSIDE TRY/CATCH
// ============================================
'use server'
import { redirect } from 'next/navigation'
export async function createPost(formData: FormData) {
let postId: string
try {
const post = await db.post.create({ data: { title: formData.get('title') } })
postId = post.id
} catch (e) {
return { error: 'Failed to create post' }
}
redirect(`/posts/${postId}`) // outside try/catch
}
// ============================================
// PART 10: THE DIGEST IN PRODUCTION
// ============================================
// In production, a server error's message is replaced.
// The client sees: "An error occurred in the Server Components render."
// The client also sees: error.digest = "abc123"
// The server logs contain the full stack trace with the same digest.
// Use the digest to correlate the client report with the server log.
The ten parts covered the basic boundary, what it catches, what it misses, manual event handling, global-error, notFound, the not-found boundary, Server Action try/catch, redirect placement, and the production digest.
Quick Reference
Error files
| File | Location | Catches | Must be Client Component |
|---|---|---|---|
error.js | Route segment | Rendering errors in segment and below | Yes |
global-error.js | app/ root | Root layout errors | Yes |
not-found.js | Route segment or root | notFound() calls | No |
error.js props
| Prop | Type | Purpose |
|---|---|---|
error | Error & { digest?: string } | The thrown error |
retry | () => void | Re-fetch and re-render children |
reset | () => void | Clear error state, re-render without re-fetch |
What error boundaries catch
| Scenario | Caught |
|---|---|
| Render error in page | Yes |
| Render error in nested layout | Yes |
| Render error in same-segment layout | No |
| Event handler error | No |
| Async callback after render | No |
notFound() call | No (goes to not-found.js) |
Error categories
| Category | Handling |
|---|---|
| Expected (validation, missing record) | try/catch, conditional render, notFound() |
| Uncaught exception (bug) | Throw, caught by error.js |
| Event handler error | Manual try/catch + state |
| Server Action error | try/catch in action, return result |
Best Practices
✅ Do This:
// Place error.tsx at the segment level for granular containment
app/dashboard/error.tsx // ✅
// Use retry() for transient failures
<button onClick={() => retry()}>Try again</button> // ✅
// Add global-error.tsx for root layout failures
app/global-error.tsx // ✅
// Catch Server Action errors and return structured results
try { await db.delete(...) } catch { return { error: '...' } } // ✅
// Use notFound() for expected 404s
if (!post) notFound() // ✅
❌ Don’t Do This:
// Expect error.js to catch event handler errors
const handleClick = () => { throw new Error() } // ❌
// Put redirect() inside try/catch
try { await db.create(...); redirect('/') } catch {} // ❌
// Rely on error.js to catch root layout errors
// Only global-error.js does that // ❌
// Use error.js for expected validation failures
// Use conditional rendering or notFound() // ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Event handler errors crash the app | Error boundaries only catch render errors | Wrap in try/catch, store in state |
| Root layout error shows default 500 | No global-error.js | Add app/global-error.tsx |
redirect() is swallowed | redirect throws, caught by try/catch | Move redirect outside the try block |
error.js does not catch layout error | Boundary is inside the layout | Place error.js in the parent segment |
| Production message is generic | Server errors are redacted for security | Use error.digest to match server logs |
notFound() triggers error boundary | notFound throws a control-flow error | Use not-found.js, not error.js |
Real-World Examples
1. Basic error boundary
'use client'
export default function Error({ error, retry }) {
return <button onClick={() => retry()}>Try again</button>
}
2. Global error
'use client'
export default function GlobalError({ error, retry }) {
return <html><body><button onClick={() => retry()}>Reload</button></body></html>
}
3. Not-found page
export default function NotFound() {
return <h2>Page not found</h2>
}
4. Trigger notFound
import { notFound } from 'next/navigation'
if (!post) notFound()
5. Event handler error
const [error, setError] = useState(null)
try { risky() } catch (e) { setError(e) }
6. Server Action error
try { await db.delete(...) } catch { return { error: 'Failed' } }
7. Redirect outside try
try { await db.create(...) } catch { return { error } }
redirect('/posts')
8. Log error in boundary
useEffect(() => { console.error(error) }, [error])
9. Use digest
console.error('Digest:', error.digest)
10. Catch error and return
return { error: 'Something went wrong' }
Visual
Error boundary containment
┌─────────────────────────────────────────────────────────────┐
│ ERROR BOUNDARY CONTAINMENT │
│ │
│ app/dashboard/layout.tsx (above boundary) │
│ └── error.js (boundary starts here) │
│ ├── page.tsx (caught) │
│ ├── loading.js (caught) │
│ ├── not-found.js (caught) │
│ └── nested/layout.tsx (caught) │
│ │
│ A throw in page.tsx → error.js renders fallback │
│ A throw in layout.tsx → error.js cannot catch it │
└─────────────────────────────────────────────────────────────┘
What error.js catches
┌─────────────────────────────────────────────────────────────┐
│ CAUGHT NOT CAUGHT │
│ │
│ Render errors Event handler errors │
│ Async Server Component throws Async after render │
│ Component lifecycle errors notFound() calls │
│ Nested layout render errors Same-segment layout errors │
│ │
│ Error boundary = rendering only. │
└─────────────────────────────────────────────────────────────┘
The production digest
┌─────────────────────────────────────────────────────────────┐
│ SERVER ERROR IN PRODUCTION │
│ │
│ Server logs: │
│ Error: Database connection failed │
│ at db.query (/app/lib/db.ts:42) │
│ digest: "abc123" │
│ │
│ Client fallback: │
│ error.message = "An error occurred in the Server │
│ Components render." │
│ error.digest = "abc123" │
│ │
│ The digest connects the client report to the server log. │
└─────────────────────────────────────────────────────────────┘
Error category decision
┌─────────────────────────────────────────────────────────────┐
│ WHICH MECHANISM? │
│ │
│ Is this an expected failure? │
│ │ │
│ ├── Yes (validation, missing record) │
│ │ ├── Missing record ──► notFound() → not-found.js │
│ │ └── Validation ──► conditional render / return │
│ │ │
│ └── No (bug, unexpected throw) │
│ └── throw → error.js boundary │
│ │
│ Is it in the root layout? │
│ └── Yes ──► global-error.js │
└─────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Segment error boundary | error.js |
| Root error boundary | global-error.js |
| Not-found handler | not-found.js + notFound() |
| error.js requirement | 'use client' |
| error.js props | error, retry (and reset) |
| Production server error | Generic message + digest |
| Event handler errors | Manual try/catch + state |
| Server Action errors | try/catch, return result |
redirect placement | Outside try/catch |
Key takeaways:
error.jsis a route-segment boundary for rendering errors. It catches throws in the segment’s page and nested children, but not in the layout above it, not in event handlers, and not innotFound()calls .global-error.jsis the only boundary that catches root layout errors. It replaces the root layout, so it must render its own<html>and<body>tags and does not inherit the root layout’s styles .- Production server errors are redacted. The client sees a generic message and a
digesthash. The full stack trace is in the server logs, keyed by the same digest. Use the digest to correlate the two . - Event handler errors require manual handling. Error boundaries are for rendering. A throw in
onClickor an async callback after render must be caught withtry/catchand stored in state . - Server Actions should catch errors and return structured results. An uncaught throw in a Server Action propagates to the boundary during the subsequent render, but returning
{ error: '...' }gives the action caller more control over the UI . redirect()must be called outsidetry/catch. It works by throwing a control-flow error, and acatchblock would swallow it .
Remember: Error handling in the App Router is a layered system. error.js contains rendering failures at the segment level. global-error.js catches the root layout. not-found.js handles expected 404s. Event handlers and async callbacks need manual try/catch. Server Actions return structured errors. The production digest is the bridge between the safe client-facing message and the detailed server-side log. Place the boundaries at the right level, know what each one does not catch, and use retry() to give the user a way back.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!