Skip to content
Back to Build Tutorials

Error Boundary

Add error boundaries and a global error handler so crashes show a recovery path, not a white screen.

Why

A white screen on error is the worst possible UX — the user has no idea what happened, no way to recover, and no confidence the app is safe to use. An error boundary catches the crash, shows a friendly message with a 'try again' button, and logs the error with enough context to debug. Without boundaries, a single bug in a component kills the entire page; with them, only the broken component shows an error and the rest of the page works. The global handler catches unhandled promise rejections and reports them before they become silent failures. This is the difference between 'the app crashed and I lost my work' and 'one card failed to load, click to retry'.

When

Before the first user-facing route. Takes 1-2 h. Skip never — every app needs this.

How

1. Create a reusable ErrorBoundary component (class component — hooks can't catch errors)
   - getDerivedStateFromError sets hasError=true; componentDidCatch logs to Sentry
   - Render a fallback UI with the error message + a "Try again" button that resets state

2. Wrap each route segment with an error boundary (Next.js error.tsx)
   - app/dashboard/error.tsx catches errors in the dashboard route
   - Keep the boundary close to the data source so only the broken section fails

3. Add a global error handler for unhandled promise rejections
   - window.addEventListener("unhandledrejection", (e) => reportError(e.reason))
   - This catches async errors that no try/catch or .catch() handled

4. Add a global error page (Next.js global-error.tsx) for root-level errors
   - This replaces the entire layout, so it must include <html> and <body>
   - Keep it minimal: "Something went wrong" + reload button + error ID

5. Log errors with context: route, user, action, stack trace
   - Send to Sentry/PostHog with a unique error ID for support to reference

Example

"use client";
import { Component, type ReactNode } from "react";
export class ErrorBoundary extends Component<{ children: ReactNode; fallback?: ReactNode }, { hasError: boolean }> {
  state = { hasError: false };
  static getDerivedStateFromError() { return { hasError: true }; }
  componentDidCatch(error: Error, info: unknown) { Sentry.captureException(error, { extra: info }); }
  render() {
    if (this.state.hasError) return this.props.fallback ?? (
      <div className="p-8 text-center">
        <p>Something went wrong.</p>
        <button onClick={() => this.setState({ hasError: false })}>Try again</button>
      </div>
    );
    return this.props.children;
  }
}

// app/dashboard/error.tsx
export default function Error({ error, reset }: { error: Error; reset: () => void }) {
  useEffect(() => { Sentry.captureException(error); }, [error]);
  return <div><p>This section failed to load.</p><button onClick={reset}>Retry</button></div>;
}

Common Problems

  • ⚠A single root error boundary, so a bug in a sidebar kills the main content too.
  • ⚠Logging errors with console.error instead of a service, so prod errors are invisible.
  • ⚠No 'try again' button, so the user's only option is to reload and lose unsaved work.
  • ⚠Catching errors but not logging them, so the bug is hidden and never gets fixed.

Best Practices

  • ✓Place error boundaries at the route segment level, not just the root — isolate failures to the broken section.
  • ✓Include an error ID in the UI and the log so support can correlate a user report to a stack trace.
  • ✓Use React's reset() in Next.js error.tsx to retry the failed render without a full page reload.
  • ✓Set up Sentry's beforeSend to strip PII (emails, passwords) before logging — never log user secrets.