· 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-level error.tsx.
  • Errors inside the UserProfileForm (e.g., form submission) are caught by the local ErrorBoundary.
  • 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.

Back to Blog

Related Posts

View All Posts »

Advanced Tailwind tips and tricks

Unlock Tailwind CSS's potential with advanced tips, dynamic sizing, responsive layouts, and more to enhance your web development workflow