· Michał Roman · Tutorials · 7 min read
UI error handling in Next.js 19 with Error Boundaries
Handling errors properly in the UI is often one of the last things developers think about. But once your app grows, you realize how important it is to keep your interface from crashing and show something meaningful to users when things go wrong.
UI error handling in Next.js 19 with Error Boundaries
Handling errors properly in the UI is often one of the last things developers think about. But once your app grows, you realize how important it is to keep your interface from crashing and show something meaningful to users when things go wrong. With Next.js 19 and the App Router, we have great tools to handle UI errors more gracefully.
What is an Error Boundary?
An Error Boundary is a special type of React component. It catches JavaScript errors anywhere in its child component tree, logs those errors, and displays a fallback UI instead of crashing the entire app. It only works for errors in rendering, lifecycle methods, and constructors. It doesn’t catch things like async errors, event handler issues, or server-side exceptions.
Here’s a basic example of a reusable error boundary:
'use client';
import React from 'react';
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error) {
return { hasError: true, error };
}
componentDidCatch(error, errorInfo) {
console.error('ErrorBoundary caught an error:', error, errorInfo);
}
render() {
if (this.state.hasError) {
return <div>Something went wrong.</div>;
}
return this.props.children;
}
}
export default ErrorBoundary;
You can wrap parts of your app with this component to prevent a full crash if something breaks.
Route-level error handling with error.js
Next.js 13+ introduced the App Router, and one of its features is the error.js
file. It acts like an error boundary, but at the route level. You place it next to a page.js
or layout.js
, and if anything in that route throws an error during rendering or loading, it will show the error component instead.
Here’s an example for a blog posts route:
'use client';
export default function Error({ error, reset }) {
return (
<div>
<h2>Something went wrong</h2>
<p>{error.message}</p>
<button onClick={reset}>Try again</button>
</div>
);
}
The reset
function lets you attempt to recover by re-rendering the route. This is useful when the error is temporary or caused by something external like an API failure.
When to use error.js
vs ErrorBoundary components
If you want to handle errors globally or per route, error.js
is the right tool. If you need more fine-grained control, or want to wrap just one component (like a widget, form, or chart), use a custom ErrorBoundary
.
For example, let’s say you have a user dashboard, and one widget might throw. Instead of letting it break the whole page, you can wrap that widget:
<ErrorBoundary>
<UserStatsWidget />
</ErrorBoundary>
This way only that part breaks, and the rest of the UI keeps working.
Async and server errors
Error boundaries don’t catch async code. If something fails inside a fetch
or setTimeout
, it won’t be caught. For async functions in client components, use try/catch
blocks.
For example:
'use client';
async function fetchData() {
try {
const res = await fetch('/api/data');
if (!res.ok) throw new Error('Failed to load');
return await res.json();
} catch (err) {
console.error(err);
// show fallback UI or set error state
}
}
In server components, if you throw an error during data fetching or rendering, and you have an error.js
in place, it will catch it.
export default async function PostsPage() {
const res = await fetch('https://api.example.com/posts');
if (!res.ok) throw new Error('Failed to fetch posts');
const posts = await res.json();
return <PostList posts={posts} />;
}
If this throws, and there’s a matching error.tsx
in the same folder, that error component will be shown.
Logging errors with Sentry (or any tool)
If you’re using something like Sentry or LogRocket, the best place to log client-side rendering issues is inside componentDidCatch
in your ErrorBoundary
.
componentDidCatch(error, errorInfo) {
Sentry.captureException(error, { extra: errorInfo });
}
This keeps your users informed with fallback UI, while you get details in your error dashboard.
Good fallback UI patterns
Your fallback UI should be user-friendly. Don’t just say “Something went wrong.” Try giving the user context, maybe offer a retry button, or link them back to a safer page. In development, you might show the error message to help with debugging, but hide it in production.
{process.env.NODE_ENV === 'development' && <pre>{error.message}</pre>}
Make sure your error UI is accessible and doesn’t block the rest of the interface.
Testing error boundaries
You can test error boundaries by simulating an error in a child component and checking if the fallback is shown.
test('shows fallback on error', () => {
const ProblemChild = () => { throw new Error('Boom'); };
render(
<ErrorBoundary>
<ProblemChild />
</ErrorBoundary>
);
expect(screen.getByText(/something went wrong/i)).toBeInTheDocument();
});
Make sure your error handling logic is tested just like any other critical logic.
Common pitfalls
Error boundaries only work in client components, so don’t forget the 'use client'
directive. They don’t catch async errors, so you still need try/catch
in those cases. They also don’t catch errors in server components — that’s where error.js
comes in. Always test in both development and production because some behavior can vary.
Final thoughts
Next.js 19 makes error handling easier and more flexible, especially with the App Router. Use error.js
for route-level fallback UI, and custom error boundaries for local control. Don’t wait until things break in production. Add proper error handling early. It saves you time and gives your users a smoother experience.
More complex example
A bit more complex example (at least the most interesting part) that combines:
- A custom
ErrorBoundary
for local error handling. - Async data fetching inside a client component.
- React context to manage form state.
- A form that might throw errors during submission or fetching.
- Fallback UI when something breaks.
We’re building a UserProfileForm
where we:
- Fetch initial user data.
- Use context to manage form data globally in the component subtree.
- Wrap everything in an
ErrorBoundary
.
app/profile/error.tsx
(route-level error handling)
'use client';
export default function Error({ error, reset }: { error: Error; reset: () => void }) {
return (
<div>
<h2>Oops, something broke.</h2>
<p>{error.message}</p>
<button onClick={reset}>Try again</button>
</div>
);
}
app/profile/form/ErrorBoundary.tsx
'use client';
import React from 'react';
export class ErrorBoundary extends React.Component<
{ children: React.ReactNode },
{ hasError: boolean; error?: Error }
> {
constructor(props: any) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error: Error) {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error('Form error:', error, errorInfo);
}
render() {
if (this.state.hasError) {
return <div>Something went wrong in the form.</div>;
}
return this.props.children;
}
}
app/profile/form/UserContext.tsx
'use client';
import React, { createContext, useContext, useState } from 'react';
type UserFormData = {
name: string;
email: string;
};
type ContextType = {
formData: UserFormData;
setFormData: React.Dispatch<React.SetStateAction<UserFormData>>;
};
const UserFormContext = createContext<ContextType | null>(null);
export const useUserForm = () => {
const context = useContext(UserFormContext);
if (!context) throw new Error('useUserForm must be used within a UserFormProvider');
return context;
};
export function UserFormProvider({ children, initialData }: { children: React.ReactNode; initialData: UserFormData }) {
const [formData, setFormData] = useState(initialData);
return (
<UserFormContext.Provider value={{ formData, setFormData }}>
{children}
</UserFormContext.Provider>
);
}
app/profile/form/UserProfileForm.tsx
'use client';
import { useUserForm } from './UserContext';
import { useState } from 'react';
export default function UserProfileForm() {
const { formData, setFormData } = useUserForm();
const [status, setStatus] = useState<'idle' | 'submitting' | 'error' | 'success'>('idle');
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setStatus('submitting');
try {
const res = await fetch('/api/save-profile', {
method: 'POST',
body: JSON.stringify(formData),
headers: {
'Content-Type': 'application/json',
},
});
if (!res.ok) throw new Error('Failed to save');
setStatus('success');
} catch (err) {
setStatus('error');
throw err;
}
}
return (
<form onSubmit={handleSubmit}>
<label>
Name:
<input
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
/>
</label>
<br />
<label>
Email:
<input
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
/>
</label>
<br />
<button type="submit" disabled={status === 'submitting'}>
Save
</button>
{status === 'error' && <p>Failed to submit. Try again.</p>}
{status === 'success' && <p>Profile saved!</p>}
</form>
);
}
app/profile/page.tsx
import { ErrorBoundary } from './form/ErrorBoundary';
import { UserFormProvider } from './form/UserContext';
import UserProfileForm from './form/UserProfileForm';
async function fetchUserData() {
const res = await fetch('https://api.example.com/user');
if (!res.ok) throw new Error('Failed to load user data');
return res.json();
}
export default async function ProfilePage() {
const user = await fetchUserData();
return (
<ErrorBoundary>
<UserFormProvider initialData={user}>
<UserProfileForm />
</UserFormProvider>
</ErrorBoundary>
);
}
To summarize:
- Errors during
fetchUserData()
are caught by the route-levelerror.tsx
. - Errors inside the
UserProfileForm
(e.g., form submission) are caught by the localErrorBoundary
. - The context manages state and is shared across deeply nested components.
- The form provides feedback and handles submit errors without crashing the UI.
This is just the beginning, it can be easily extended to cover more. I only wanted to show the basics.