Skip to main content

Next.js Error Boundaries

Introduction

Error handling is a crucial aspect of building robust web applications. In React and Next.js applications, unexpected errors can cause the entire component tree to unmount, resulting in a blank screen or broken UI. Error Boundaries provide a way to gracefully handle these runtime errors, preventing the entire application from crashing and giving you the opportunity to display fallback UI to your users.

Error Boundaries were introduced in React 16 and are fully supported in Next.js. They act as a JavaScript catch {} block but for React components, catching errors during rendering, in lifecycle methods, and in constructors of the entire component tree below them.

In this tutorial, we'll learn how to implement and use Error Boundaries in Next.js applications to create more resilient user experiences.

What Are Error Boundaries?

Error Boundaries are React components that:

  1. Catch JavaScript errors anywhere in their child component tree
  2. Log those errors
  3. Display a fallback UI instead of the component tree that crashed

It's important to understand that Error Boundaries do not catch errors in:

  • Event handlers
  • Asynchronous code (e.g., setTimeout or requestAnimationFrame callbacks)
  • Server-side rendering (SSR)
  • Errors thrown in the Error Boundary itself

Creating an Error Boundary Component in Next.js

Since Error Boundaries are implemented as class components and Next.js primarily uses functional components, we'll need to create a custom Error Boundary component.

Let's create a basic Error Boundary component:

jsx
// components/ErrorBoundary.js
import React from 'react';

class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false };
}

static getDerivedStateFromError(error) {
// Update state so the next render will show the fallback UI
return { hasError: true };
}

componentDidCatch(error, errorInfo) {
// You can log the error to an error reporting service
console.error("Error caught by Error Boundary:", error, errorInfo);
}

render() {
if (this.state.hasError) {
// You can render any custom fallback UI
return this.props.fallback || (
<div className="error-container">
<h2>Something went wrong</h2>
<button onClick={() => this.setState({ hasError: false })}>
Try again
</button>
</div>
);
}

return this.props.children;
}
}

export default ErrorBoundary;

This component implements two key lifecycle methods:

  1. static getDerivedStateFromError(): Used to render a fallback UI after an error has been thrown.
  2. componentDidCatch(): Used to log error information for debugging.

Using Error Boundaries in Next.js Pages

Now that we have our Error Boundary component, let's see how to use it in a Next.js application:

jsx
// pages/index.js
import ErrorBoundary from '../components/ErrorBoundary';
import BuggyComponent from '../components/BuggyComponent';

export default function Home() {
return (
<div className="container">
<h1>Error Boundary Example</h1>

<ErrorBoundary fallback={<p>Something went wrong with the counter!</p>}>
<BuggyComponent />
</ErrorBoundary>

<p>The rest of the application continues to work!</p>
</div>
);
}

Let's create a buggy component that will throw an error:

jsx
// components/BuggyComponent.js
import { useState } from 'react';

export default function BuggyComponent() {
const [counter, setCounter] = useState(0);

if (counter === 5) {
// Simulate an error when counter reaches 5
throw new Error("Counter reached 5!");
}

return (
<div>
<h2>Counter: {counter}</h2>
<button onClick={() => setCounter(counter + 1)}>
Increase Counter
</button>
<p>Click 5 times to see the Error Boundary in action</p>
</div>
);
}

When the user clicks the button 5 times, BuggyComponent will throw an error. Instead of crashing the entire application, the Error Boundary will catch this error and display the fallback UI.

Creating Specialized Error Boundaries

You might want to create specialized Error Boundaries for different parts of your application. For example, let's create an Error Boundary specifically for data fetching:

jsx
// components/DataFetchingErrorBoundary.js
import React from 'react';
import ErrorBoundary from './ErrorBoundary';

export default function DataFetchingErrorBoundary({ children }) {
return (
<ErrorBoundary
fallback={
<div className="data-error">
<h3>Failed to load data</h3>
<p>There was a problem loading the requested data. Please try again later.</p>
<button onClick={() => window.location.reload()}>
Reload Page
</button>
</div>
}
>
{children}
</ErrorBoundary>
);
}

Nesting Error Boundaries

Error Boundaries can be nested to create more granular error handling. This is particularly useful in complex applications where you want to isolate errors to specific sections:

jsx
// pages/dashboard.js
import ErrorBoundary from '../components/ErrorBoundary';
import DataFetchingErrorBoundary from '../components/DataFetchingErrorBoundary';
import UserProfile from '../components/UserProfile';
import Analytics from '../components/Analytics';
import RecentActivity from '../components/RecentActivity';

export default function Dashboard() {
return (
<ErrorBoundary fallback={<p>Something went wrong with the dashboard</p>}>
<h1>Dashboard</h1>

<div className="dashboard-grid">
<DataFetchingErrorBoundary>
<UserProfile userId="123" />
</DataFetchingErrorBoundary>

<ErrorBoundary fallback={<p>Analytics component failed to load</p>}>
<Analytics />
</ErrorBoundary>

<DataFetchingErrorBoundary>
<RecentActivity />
</DataFetchingErrorBoundary>
</div>
</ErrorBoundary>
);
}

This approach ensures that if one section of the dashboard fails, the rest will continue to function.

Best Practices for Error Boundaries in Next.js

1. Place Error Boundaries Strategically

It's not necessary to wrap every component in an Error Boundary. Think about the user experience when deciding where to place them. Good places include:

  • Route-level components
  • Important UI sections
  • Areas with complex data operations
  • Third-party components

2. Provide Helpful Recovery Options

When designing fallback UIs, include clear recovery options for users:

jsx
// components/ImprovedErrorBoundary.js
class ImprovedErrorBoundary extends React.Component {
// ... other methods

render() {
if (this.state.hasError) {
return (
<div className="error-container">
<h2>Something went wrong</h2>
<p>We apologize for the inconvenience.</p>
<div className="recovery-options">
<button onClick={() => this.setState({ hasError: false })}>
Try Again
</button>
<button onClick={() => window.location.reload()}>
Reload Page
</button>
<a href="/" className="button">
Return to Homepage
</a>
</div>
</div>
);
}

return this.props.children;
}
}

3. Log Errors for Monitoring

In a production environment, you should log errors to a monitoring service:

jsx
componentDidCatch(error, errorInfo) {
// Log to console during development
console.error("Error caught:", error, errorInfo);

// Log to a service in production
if (process.env.NODE_ENV === 'production') {
logErrorToService(error, errorInfo);
}
}

4. Combine with Next.js Error Pages

For page-level errors, consider combining Error Boundaries with Next.js custom error pages:

jsx
// pages/_error.js
function CustomError({ statusCode }) {
return (
<div className="error-page">
<h1>Error {statusCode}</h1>
<p>
{statusCode
? `An error ${statusCode} occurred on the server`
: 'An error occurred on the client'}
</p>
<a href="/">Return to homepage</a>
</div>
);
}

CustomError.getInitialProps = ({ res, err }) => {
const statusCode = res ? res.statusCode : err ? err.statusCode : 404;
return { statusCode };
};

export default CustomError;

Real-World Example: Form Submission

Let's see a practical example of using Error Boundaries to handle errors in a form submission component:

jsx
// components/ContactForm.js
import { useState } from 'react';

export default function ContactForm() {
const [formData, setFormData] = useState({ name: '', email: '', message: '' });
const [isSubmitting, setIsSubmitting] = useState(false);

const handleChange = (e) => {
setFormData({ ...formData, [e.target.name]: e.target.value });
};

const handleSubmit = async (e) => {
e.preventDefault();
setIsSubmitting(true);

try {
const response = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData),
});

if (!response.ok) {
throw new Error('Failed to submit form');
}

// Reset form on success
setFormData({ name: '', email: '', message: '' });
alert('Message sent successfully!');
} catch (error) {
// This error won't be caught by Error Boundary
// because it's in an event handler
alert('Failed to send message. Please try again.');
console.error('Form submission error:', error);
} finally {
setIsSubmitting(false);
}
};

return (
<form onSubmit={handleSubmit}>
<div className="form-group">
<label htmlFor="name">Name</label>
<input
type="text"
id="name"
name="name"
value={formData.name}
onChange={handleChange}
required
/>
</div>
<div className="form-group">
<label htmlFor="email">Email</label>
<input
type="email"
id="email"
name="email"
value={formData.email}
onChange={handleChange}
required
/>
</div>
<div className="form-group">
<label htmlFor="message">Message</label>
<textarea
id="message"
name="message"
value={formData.message}
onChange={handleChange}
required
/>
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Sending...' : 'Send Message'}
</button>
</form>
);
}

Now, let's use this form with an Error Boundary:

jsx
// pages/contact.js
import ErrorBoundary from '../components/ErrorBoundary';
import ContactForm from '../components/ContactForm';

export default function Contact() {
return (
<div className="container">
<h1>Contact Us</h1>

<ErrorBoundary fallback={
<div>
<p>Sorry, there was a problem with our contact form.</p>
<p>Please email us directly at: [email protected]</p>
</div>
}>
<ContactForm />
</ErrorBoundary>
</div>
);
}

Remember that the Error Boundary won't catch errors in the form submission handler (since it's an event handler), but it will catch rendering errors that might occur in the form component.

Handling Async Errors

For asynchronous errors that Error Boundaries can't catch, we need to combine Error Boundaries with state management. Here's a pattern for handling async data fetching errors:

jsx
// components/ProductList.js
import { useState, useEffect } from 'react';

export default function ProductList() {
const [products, setProducts] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);

useEffect(() => {
async function fetchProducts() {
try {
const response = await fetch('/api/products');

if (!response.ok) {
throw new Error('Failed to fetch products');
}

const data = await response.json();
setProducts(data);
} catch (err) {
setError(err);
} finally {
setLoading(false);
}
}

fetchProducts();
}, []);

if (loading) return <p>Loading products...</p>;

// If there's an error, deliberately throw it during render
// so the Error Boundary can catch it
if (error) {
throw error;
}

return (
<ul className="product-list">
{products.map(product => (
<li key={product.id}>
<h3>{product.name}</h3>
<p>${product.price}</p>
</li>
))}
</ul>
);
}

Using it with an Error Boundary:

jsx
// pages/products.js
import ErrorBoundary from '../components/ErrorBoundary';
import ProductList from '../components/ProductList';

export default function ProductsPage() {
return (
<div className="container">
<h1>Our Products</h1>

<ErrorBoundary fallback={
<div>
<p>Unable to load products at this time.</p>
<button onClick={() => window.location.reload()}>
Try Again
</button>
</div>
}>
<ProductList />
</ErrorBoundary>
</div>
);
}

Summary

Error Boundaries are a powerful feature in React and Next.js that help you create more resilient applications by gracefully handling runtime errors. By strategically placing Error Boundaries throughout your application, you can:

  • Prevent the entire UI from breaking when errors occur
  • Provide helpful fallback UI and recovery options
  • Log errors for monitoring and debugging
  • Create a better user experience

Remember key points about Error Boundaries:

  • They're implemented as class components with specific lifecycle methods
  • They catch errors during rendering, lifecycle methods, and constructors
  • They don't catch errors in event handlers, async code, or SSR
  • They should be placed strategically, not around every component

By combining Error Boundaries with good state management practices for async operations, you can build Next.js applications that handle errors gracefully and provide a smooth experience for your users.

Additional Resources

Exercises

  1. Create an Error Boundary component that displays different fallback UIs based on the type of error.
  2. Implement a "retry" mechanism in your Error Boundary that attempts to remount the failed component.
  3. Create a Higher Order Component (HOC) that wraps components with an Error Boundary.
  4. Build a form with validation that uses Error Boundaries to catch rendering errors.
  5. Integrate an error monitoring service like Sentry with your Error Boundary's componentDidCatch method.


If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)