# Error Boundaries in Next.js App Router — Handling Failures Gracefully

> Source: <https://dev.to/aon_infotech_3a1b6ff525fc/error-boundaries-in-nextjs-app-router-handling-failures-gracefully-3mif>
> Published: 2026-06-22 08:53:21+00:00

Most Next.js applications handle the happy path well. A request comes in, data loads, components render, user sees the page. Error handling is where applications often reveal their real quality — and where App Router introduces some nuances worth understanding.

Here's how error boundaries work in App Router, what the `error.js`

file actually does, and the patterns that make failure handling feel intentional rather than afterthought. This is the approach I use in production for [a free AI image generator for beginners](https://pixova.io/blog/what-is-text-to-image-ai) where graceful degradation matters more than on sites with simpler data flows.

Pages Router had `_error.js`

and `getInitialProps`

for error handling. App Router introduces a different model built on React's Error Boundary concept, with `error.js`

files that can be nested at any level of the route hierarchy.

The key mental model shift: errors are contained at the nearest `error.js`

boundary, not propagated to a global handler. This means you can have different error UIs for different sections of your app.

`error.js`

File
Place an `error.js`

file in any route segment to catch errors in that segment and its children:

```
app/
├── layout.js          # Root layout
├── error.js           # Catches errors in root segment
├── page.js
├── dashboard/
│   ├── error.js       # Catches errors in dashboard only
│   ├── layout.js
│   └── page.js
└── blog/
    ├── error.js       # Catches errors in blog only
    └── [slug]/
        └── page.js
// app/error.js
'use client'; // Error components must be Client Components

import { useEffect } from 'react';

export default function Error({ error, reset }) {
  useEffect(() => {
    // Log to error reporting service
    console.error(error);
  }, [error]);

  return (
    <div className="flex flex-col items-center justify-center 
      min-h-[400px] gap-4 p-6 text-center">
      <h2 className="text-xl font-semibold text-foreground">
        Something went wrong
      </h2>
      <p className="text-sm text-muted max-w-sm">
        {error.message || 'An unexpected error occurred.'}
      </p>
      <button
        onClick={reset}
        className="px-4 py-2 bg-orange-500 text-white 
          rounded-full text-sm font-medium 
          hover:bg-orange-600 transition-colors"
      >
        Try again
      </button>
    </div>
  );
}
```

**Important:** `error.js`

must be a Client Component (`'use client'`

). React Error Boundaries are a client-side concept — server errors get converted to client-side error events before reaching the boundary.

The `reset`

function retries the failed render. Use it for transient errors (network issues, temporary service failures) rather than permanent ones.

`reset`

Function — When to Show It
The `reset`

button only makes sense for errors that might succeed on retry:

```
export default function Error({ error, reset }) {
  // Check if error is retryable
  const isRetryable = error.digest !== 'NEXT_NOT_FOUND';

  return (
    <div className="error-container">
      <h2>Something went wrong</h2>
      <p>{getErrorMessage(error)}</p>

      {isRetryable && (
        <button onClick={reset}>Try again</button>
      )}

      {!isRetryable && (
        <a href="/">Return home</a>
      )}
    </div>
  );
}

function getErrorMessage(error) {
  if (error.message?.includes('fetch')) {
    return 'Failed to load data. Check your connection.';
  }
  if (error.message?.includes('timeout')) {
    return 'Request timed out. Please try again.';
  }
  return 'An unexpected error occurred.';
}
```

`global-error.js`

The root `error.js`

doesn't catch errors in the root `layout.js`

. For that, you need `global-error.js`

:

```
// app/global-error.js
'use client';

export default function GlobalError({ error, reset }) {
  return (
    <html>
      <body>
        <div className="global-error">
          <h1>Application Error</h1>
          <p>Something went wrong at the application level.</p>
          <button onClick={reset}>Reload</button>
        </div>
      </body>
    </html>
  );
}
```

`global-error.js`

replaces the root layout when it renders, so it needs to include `<html>`

and `<body>`

tags. This is the last-resort catch for errors that escape all other boundaries.

Server components can throw errors directly, but handling them well requires a pattern slightly different from what you might expect:

``` js
// app/blog/[slug]/page.js
async function getBlogPost(slug) {
  const response = await fetch(`/api/posts/${slug}`);

  if (response.status === 404) {
    notFound(); // Triggers not-found.js, not error.js
  }

  if (!response.ok) {
    throw new Error(`Failed to fetch post: ${response.status}`);
    // This triggers error.js
  }

  return response.json();
}

export default async function BlogPost({ params }) {
  const post = await getBlogPost(params.slug);
  return <PostContent post={post} />;
}
```

** notFound() vs throw Error():** Use

`notFound()`

for expected missing resources — it renders `not-found.js`

and returns a 404 status. Use `throw`

for unexpected failures — it renders `error.js`

.`not-found.js`

File
For 404-style errors, `not-found.js`

provides a cleaner separation than `error.js`

:

``` python
// app/not-found.js
import Link from 'next/link';

export default function NotFound() {
  return (
    <div className="flex flex-col items-center justify-center 
      min-h-[400px] gap-4 text-center p-6">
      <h2 className="text-2xl font-semibold">Page not found</h2>
      <p className="text-muted text-sm">
        The page you're looking for doesn't exist or has been moved.
      </p>
      <Link 
        href="/"
        className="px-4 py-2 bg-foreground text-background 
          rounded-full text-sm font-medium"
      >
        Return home
      </Link>
    </div>
  );
}
```

Client components that fetch data need their own error handling since they're outside the server component error flow:

``` js
'use client';
import { useState, useEffect } from 'react';

export function DataFetcher({ url }) {
  const [data, setData] = useState(null);
  const [error, setError] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    async function fetchData() {
      try {
        setLoading(true);
        setError(null);
        const response = await fetch(url);
        if (!response.ok) throw new Error(`HTTP ${response.status}`);
        const json = await response.json();
        setData(json);
      } catch (err) {
        setError(err.message);
      } finally {
        setLoading(false);
      }
    }

    fetchData();
  }, [url]);

  if (loading) return <Skeleton />;

  if (error) return (
    <div className="error-inline">
      <p className="text-sm text-red-500">{error}</p>
      <button 
        onClick={() => fetchData()}
        className="text-xs text-muted hover:text-foreground"
      >
        Retry
      </button>
    </div>
  );

  return <DataDisplay data={data} />;
}
```

`error.js`

is the right place to integrate with error monitoring:

``` js
'use client';
import { useEffect } from 'react';

export default function Error({ error, reset }) {
  useEffect(() => {
    // Send to your error monitoring service
    reportError({
      message: error.message,
      digest: error.digest, // Next.js server error ID
      stack: error.stack,
      timestamp: new Date().toISOString(),
      path: window.location.pathname,
    });
  }, [error]);

  return (
    // Error UI
  );
}

async function reportError(errorData) {
  try {
    await fetch('/api/errors', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(errorData),
    });
  } catch {
    // Don't let error reporting itself cause more errors
    console.error('Failed to report error:', errorData);
  }
}
```

The `error.digest`

is particularly useful — it's a hash that Next.js uses to identify server-side errors in logs without exposing sensitive stack traces to the client.

For a real application, the error boundary structure might look like:

```
global-error.js        ← Last resort, includes <html>
├── error.js           ← Root-level errors
│   ├── (auth)/
│   │   └── error.js   ← Auth-specific errors  
│   ├── dashboard/
│   │   └── error.js   ← Dashboard-specific errors
│   └── api/
│       └── route.js   ← API routes handle their own errors
```

This lets you show relevant error messages — "Your session expired, please log in again" for auth errors, "Failed to load dashboard data" for dashboard errors — rather than a generic "something went wrong" for everything.

This error handling pattern runs in production on [pixova.io](https://pixova.io/blog/what-is-text-to-image-ai). The generation pipeline has several async steps where things can go wrong — API timeouts, inference failures, upload errors — and the error boundary setup means failures at each step show appropriate messages rather than breaking the whole page.

The `reset`

button is particularly important for generation tools where users are mid-workflow when something fails — it retries without losing the prompt they just wrote.

Questions on specific error scenarios? Drop them in the comments.
