Complete Guide to Next.js Error Boundary: 5 Key Techniques for Handling Runtime Errors Gracefully

3 AM. Phone buzzes. Eyes open to see the ops team chat already exploding: “Homepage won’t load! Just a blank white screen!”
Open the monitoring dashboard, heart sinks—some third-party component crashed and took the entire page down with it. Users see nothing but blank whiteness, not even an “error occurred” message.
Honestly, I’ve been through this more than once. You’ve probably experienced similar scenarios: a production site running fine suddenly crashes because of a wrong data format or API timeout. Traditional try-catch can’t touch the React component rendering layer, leaving users staring at a blank screen before silently closing the page.
According to user experience research, blank screens cause over 80% of users to bounce immediately. That number is pretty scary.
Fortunately, Next.js provides the Error Boundary mechanism to handle these runtime errors gracefully. Not only can it prevent blank screens, but it can also give users a friendly fallback interface and even provide a “retry” button for self-recovery. This article covers the complete usage of Next.js Error Boundary—from basic error.tsx to global fallback with global-error.tsx, plus special handling for Server Components.
After reading this, you’ll know how to make your app fail gracefully and avoid those 3 AM bug-fixing wake-up calls.
Why Need Error Boundary? Limitations of Traditional Error Handling
When I first started using React, I thought try-catch could handle all errors. Reality proved me wrong pretty quickly.
Three Fatal Flaws of try-catch
First: it only catches synchronous code errors. You write JSON.parse(badData) in a try block? Caught. But component rendering errors? Sorry, not caught.
Second is even trickier: async errors in event handlers. Say you call an API in a click event and the API fails—try-catch can’t help. Why? Because when async code executes, the try-catch context is long gone.
Third is the killer: React component rendering errors. Your component’s return statement accesses an undefined property—instant blank screen. try-catch is completely useless here.
How React Error Boundary Works
React actually realized this problem early on and introduced the Error Boundary mechanism. The principle is simple: the component tree is like Russian nesting dolls—errors “bubble” from inner layers outward until they hit the nearest Error Boundary component.
The traditional approach is writing a class component implementing componentDidCatch and getDerivedStateFromError lifecycle methods. Honestly, having to write class components every time is pretty annoying. Plus, many people now prefer function components, and these methods don’t work there at all.
Next.js’s Elegant Solution
After Next.js 13 introduced App Router, it wrapped Error Boundary in a super simple way. Just create an error.tsx file in a route directory, and it automatically becomes that route’s error boundary. No class components, no manual state management—Next.js handles everything.
Another key point: Next.js’s Error Boundary can handle both server-side and client-side errors. When Server Components error during server rendering, they’re caught by the nearest error.tsx. This is impossible in traditional React.
The only caveat is that error.tsx files themselves must be client components, with a 'use client' directive at the top. Why? Because they need React hooks to handle error state and recovery logic, and hooks only run on the client.
Facebook Messenger is a classic example. They wrap sidebar, dialogs, and message input in separate Error Boundaries. One area crashes, others keep working. Users might not even notice something went wrong.
This is the core value of Error Boundary: preventing local errors from becoming global disasters.
error.tsx Usage - Local Error Boundaries
Alright, let’s get practical. How do you actually write error.tsx?
Basic Structure: 5-Minute Setup
Create error.tsx in any route directory and paste this code:
'use client'
import { useEffect } from 'react'
export default function Error({
error,
reset
}: {
error: Error & { digest?: string }
reset: () => void
}) {
useEffect(() => {
// Report to monitoring platform like Sentry
console.error('Caught error:', error)
}, [error])
return (
<div className="flex flex-col items-center justify-center min-h-screen p-4">
<h2 className="text-2xl font-bold mb-4">Oops, something went wrong</h2>
<p className="text-gray-600 mb-4">
{error.message || 'Failed to load page'}
</p>
<button
onClick={() => reset()}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
>
Try again
</button>
</div>
)
}Key points:
- Don’t skip ‘use client’: This opening line is mandatory, or Next.js will error
- error object: Contains error info and stack trace, plus a
digestfield added in Next.js 15 for error tracking - reset function: Clicking re-renders the error boundary’s content, giving users a self-rescue option
Error Bubbling Mechanism: Like an Elevator Going Up Levels
This mechanism is admittedly a bit confusing at first. Let me draw out a directory structure to clarify:
app/
├── layout.tsx # Root layout
├── error.tsx # Catches root route errors (A)
├── page.tsx # Home page
├── dashboard/
│ ├── layout.tsx # Dashboard layout
│ ├── error.tsx # Catches dashboard errors (B)
│ └── page.tsx # Dashboard page
└── profile/
└── page.tsx # Profile pageIf dashboard/page.tsx errors during rendering, who catches it? Answer: (B)—the nearest parent error.tsx.
What about profile/page.tsx errors? The profile directory has no error.tsx, so the error continues bubbling up and gets caught by (A).
Watch out for this gotcha: error.tsx can’t catch errors in sibling layout.tsx files. Why? Because the error boundary itself is wrapped inside the layout—if the layout crashes, the error boundary hasn’t loaded yet. To catch dashboard/layout.tsx errors, you need to handle them in app/error.tsx.
Correct Usage of reset()
The reset function sounds magical but just re-renders the error component’s subtree. It’s meant for transient errors like:
- API request timeouts (retry might succeed)
- Network hiccups causing resource loading failures
- Edge cases triggered by user input
But if it’s a code bug like accessing undefined.property, clicking retry won’t help. In that case, you need to fix the code and deploy after seeing the error in your monitoring platform.
I’ve seen teams add a counter to reset logic—after 3 retries, stop showing the “retry” button and guide users to refresh the page or contact support. Pretty practical approach:
'use client'
import { useEffect, useState } from 'react'
export default function Error({ error, reset }: {
error: Error & { digest?: string }
reset: () => void
}) {
const [retryCount, setRetryCount] = useState(0)
const handleReset = () => {
setRetryCount(prev => prev + 1)
reset()
}
return (
<div>
<h2>Something went wrong</h2>
{retryCount < 3 ? (
<button onClick={handleReset}>
Retry ({retryCount}/3)
</button>
) : (
<p>Multiple retries failed. Please refresh the page or <a href="/contact">contact us</a></p>
)}
</div>
)
}global-error.tsx - Global Error Fallback
error.tsx is powerful but has a loophole: it can’t catch errors in the root layout app/layout.tsx. That’s where global-error.tsx comes in.
When to Use global-error.tsx?
Honestly, this file rarely triggers in production. It mainly handles two catastrophic scenarios:
- Root layout.tsx initialization failure (like global state management library crashing)
- “Escaped” errors that no error.tsx caught
I think of it as the last safety net—you hope you never use it, but you must have it.
The Special Nature of global-error.tsx
Compared to regular error.tsx, global-error.tsx has a key difference: it must contain complete HTML structure—<html> and <body> tags.
Why? Because it completely replaces the root layout.tsx. Your root layout crashed, the entire page framework is gone—global-error.tsx needs to rebuild a minimally viable page from scratch.
Complete code looks like this:
'use client'
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return (
<html>
<body>
<div style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
minHeight: '100vh',
padding: '20px',
fontFamily: 'system-ui, sans-serif'
}}>
<h1>Application encountered a serious issue</h1>
<p style={{ color: '#666', marginBottom: '20px' }}>
{process.env.NODE_ENV === 'development'
? error.message
: 'We\'re working on this problem, please try again later'}
</p>
<button
onClick={() => reset()}
style={{
padding: '10px 20px',
background: '#0070f3',
color: 'white',
border: 'none',
borderRadius: '5px',
cursor: 'pointer'
}}
>
Reload application
</button>
</div>
</body>
</html>
)
}You’ll notice I used inline styles instead of Tailwind or CSS modules. Simple reason: at this point your styling system might not even be loaded—need to use the most primitive way to ensure the page is visible.
Development vs Production Environment
Worth noting: global-error.tsx only works in production. In development, Next.js continues showing that red error stack page for easier debugging.
In production, I recommend hiding technical error messages and showing only friendly prompts to users. That process.env.NODE_ENV check in the code above does exactly that. Users don’t care about “TypeError: Cannot read property ‘map’ of undefined”—they just want to know “will it work” and “when will it be fixed.”
Should You Add global-error.tsx?
My recommendation: add it. Though it triggers rarely, when it does, it’s a major incident. Having this fallback ensures users see a decent error page instead of the browser’s default “site can’t be reached.”
Like insurance—you don’t want to need it, but it’s better to have it when you do.
Special Considerations for Server Components Error Handling
Next.js 13+ Server Components bring new challenges to error handling. Server-side and client-side errors are handled differently.
Where Do Server Component Errors Go?
When I first encountered Server Components, I was genuinely confused. Server components render on the server—if they error, can client-side error.tsx catch it?
Answer: yes. Next.js passes server-side error information to the client, triggering the nearest error.tsx. But there’s an important security mechanism: in production, error messages are sanitized to prevent leaking sensitive server information.
For example, if your database connection fails, development shows the full error stack, but production users only see generic prompts like “loading failed.”
Expected Errors vs Unexpected Errors
This concept is quite important—official docs specifically emphasize it. You need to distinguish two types of errors:
Expected errors: Within business logic scope, should be explicitly handled
- Form validation failures (user input format wrong)
- API returns 404 (data doesn’t exist)
- Insufficient permissions (user not logged in)
Unexpected errors: Code bugs or system-level exceptions, should go to Error Boundary
- Database connection failures
- Third-party service crashes
- Code accessing undefined properties
For expected errors, you should handle them with try-catch in Server Actions or data-fetching functions, then return error info to components:
// app/actions.ts
'use server'
export async function createUser(formData: FormData) {
const email = formData.get('email') as string
// Expected error: invalid email format
if (!email.includes('@')) {
return { error: 'Please enter a valid email address' }
}
try {
await db.user.create({ email })
return { success: true }
} catch (error) {
// Unexpected error: database crashed, throw to Error Boundary
throw new Error('Failed to create user')
}
}For unexpected errors, just throw and let them bubble to the nearest error.tsx.
Error Handling in Data Fetching
In Server Components doing data fetching, I typically handle it like this:
// app/posts/page.tsx
async function getPosts() {
const res = await fetch('https://api.example.com/posts')
// Expected error: API returns error status code
if (!res.ok) {
// Decide whether to handle explicitly or throw based on error type
if (res.status === 404) {
return { posts: [], error: 'No data available' }
}
// Server error, throw to Error Boundary
throw new Error('Failed to fetch data')
}
return { posts: await res.json() }
}
export default async function PostsPage() {
const { posts, error } = await getPosts()
// Explicitly render error state
if (error) {
return <div>No posts available</div>
}
return (
<ul>
{posts.map(post => <li key={post.id}>{post.title}</li>)}
</ul>
)
}This approach benefits user experience. “No data available” doesn’t need to trigger an error page—only genuine system errors should show error.tsx’s fallback UI.
The Magic of error.digest
Next.js 15 added a digest field to the error object—an auto-generated unique identifier.
What’s it for? Imagine this scenario: a user sees the error page, screenshots it, and tells support “page won’t open.” Support takes that digest to search logs and can pinpoint exactly which request, when, and what error.
You can use it in error.tsx like this:
'use client'
export default function Error({ error }: { error: Error & { digest?: string }}) {
return (
<div>
<h2>An error occurred</h2>
<p>Error ID: {error.digest}</p>
<p>Please contact support with the above ID</p>
</div>
)
}Combined with Sentry or other monitoring platforms, this digest can multiply error tracking efficiency several times over.
Production Environment Best Practices
We’ve covered how to use it, now let’s talk about using it well. These are lessons learned from my own mistakes.
1. Granular Error Boundary Design
Don’t just drop one error.tsx in the root directory and call it done. Critical functional areas should have separate error boundaries.
For example, an e-commerce site could be structured like this:
app/
├── error.tsx # Fallback
├── (shop)/
│ ├── products/
│ │ └── error.tsx # Product list errors don't affect other areas
│ ├── cart/
│ │ └── error.tsx # Cart errors don't affect browsing products
│ └── checkout/
│ └── error.tsx # Checkout flow is most critical, handle separatelyThe benefit: even if the cart component crashes, users can still browse products. The entire site doesn’t become unusable.
2. Error Monitoring and Reporting
error.tsx’s useEffect is the perfect reporting moment:
'use client'
import { useEffect } from 'react'
import * as Sentry from '@sentry/nextjs'
export default function Error({ error, reset }: {
error: Error & { digest?: string }
reset: () => void
}) {
useEffect(() => {
// Report to Sentry
Sentry.captureException(error, {
tags: {
errorDigest: error.digest,
errorBoundary: 'app-root'
},
extra: {
userAgent: navigator.userAgent,
timestamp: new Date().toISOString()
}
})
}, [error])
return (
// Error UI...
)
}Remember to include error.digest and user environment info for easier reproduction.
I’ve seen teams also log users’ recent action paths (like last 5 pages visited)—hugely helpful for troubleshooting.
3. User-Friendly Error UI
Technical folks love seeing stack traces, but users don’t care about those. They want to know:
- What happened? (in plain language)
- Can it be fixed? (provide clear actions)
- Did I lose my data? (explain the impact scope)
A good error UI should look like this:
return (
<div className="error-container">
<h2>Page failed to load</h2>
<p>Could be network instability, or our servers are taking a nap</p>
<div className="actions">
<button onClick={reset}>Try again</button>
<a href="/">Return to homepage</a>
<a href="/help">Contact support</a>
</div>
<details className="error-details">
<summary>Technical info (optional)</summary>
<code>{error.digest}</code>
</details>
</div>
)Use a light tone to avoid user anxiety. “Servers taking a nap” is way friendlier than “500 Internal Server Error.”
4. Smart Retry Strategy
I mentioned limiting retry counts earlier, here are a few more tips:
- Delayed retry: Don’t reset immediately, wait 1-2 seconds to give the server breathing room
- Exponential backoff: Wait 1 second first time, 2 seconds second time, 4 seconds third time
- Distinguish error types: Network errors suggest retry, code errors prompt to contact tech support
const [retryCount, setRetryCount] = useState(0)
const [isRetrying, setIsRetrying] = useState(false)
const handleReset = async () => {
setIsRetrying(true)
setRetryCount(prev => prev + 1)
// Exponential backoff: 2^retryCount seconds
await new Promise(resolve =>
setTimeout(resolve, Math.pow(2, retryCount) * 1000)
)
setIsRetrying(false)
reset()
}5. Environment-Differentiated Handling
Development and production error displays should differ:
const isDev = process.env.NODE_ENV === 'development'
return (
<div>
<h2>{isDev ? error.message : 'Something went wrong'}</h2>
{isDev && (
<pre>
<code>{error.stack}</code>
</pre>
)}
{!isDev && (
<p>We've logged this issue and will fix it ASAP</p>
)}
</div>
)Development gives you the full stack for debugging. Production only shows friendly prompts without leaking technical details.
6. Don’t Overuse
Final reminder: Error Boundary is a fallback solution, not the primary error handling method.
If you can handle expected errors with try-catch, don’t throw them to Error Boundary. If you can gracefully degrade within a component, don’t trigger an error page.
For example, if a user avatar fails to load, just show a default avatar—no need to crash the entire profile page.
Error Boundary should be reserved for truly unexpected errors that can’t be handled locally.
Conclusion
After all that, it really boils down to three points:
First, Error Boundary isn’t optional—it’s essential. User churn from blank screens is way worse than you think. Investing time to set up error boundaries properly can prevent many 3 AM wake-up calls.
Second, layered handling is crucial. error.tsx handles local errors, global-error.tsx provides global fallback, and in Server Components distinguish expected errors from unexpected ones. Handle explicitly what should be handled, and let what should go to Error Boundary flow there.
Third, user experience first. Leave technical details for monitoring platforms—what users see should always be friendly, actionable prompts. Retry buttons can resolve 40% of transient errors—that ROI is quite solid.
Go add an error.tsx to your Next.js project right now. Start with the root directory, then gradually add error boundaries to critical functional areas. Pair it with monitoring tools like Sentry, and you’ll see application stability improve visibly.
Oh, and don’t forget global-error.tsx. Though rarely triggered, it’s like a seatbelt—you hope you never need it, but you must have it.
Implementing Error Boundary in Next.js
Add error boundaries to your Next.js app for graceful runtime error handling
- 1
Step1: Create error.tsx file
Create error.tsx in the app directory or any route directory with the 'use client' directive - 2
Step2: Implement error handling component
Define Error component receiving error and reset parameters, design a friendly error UI - 3
Step3: Add error reporting
Report errors to monitoring platforms like Sentry in useEffect, logging error.digest - 4
Step4: Implement smart retry
Add retry button with limited attempts, providing auto-recovery for transient errors - 5
Step5: Create global-error.tsx
Create global-error.tsx in app directory as ultimate fallback with complete HTML structure - 6
Step6: Distinguish error types
In Server Components, distinguish expected errors (explicit handling) from unexpected errors (Error Boundary)
FAQ
What's the difference between error.tsx and global-error.tsx?
Why must error.tsx be a client component?
Can error.tsx catch Server Component errors?
When should you use try-catch instead of Error Boundary?
How does the reset() function work?
12 min read · Published on: Jan 6, 2026 · Modified on: Jan 22, 2026
Related Posts
Next.js E-commerce in Practice: Complete Guide to Shopping Cart and Stripe Payment Implementation

Next.js E-commerce in Practice: Complete Guide to Shopping Cart and Stripe Payment Implementation
Complete Guide to Next.js File Upload: S3/Qiniu Cloud Presigned URL Direct Upload

Complete Guide to Next.js File Upload: S3/Qiniu Cloud Presigned URL Direct Upload
Next.js Unit Testing Guide: Complete Jest + React Testing Library Setup


Comments
Sign in with GitHub to leave a comment