DEV Community

Anas Sheikh
Anas Sheikh

Posted on

Error Handling in Next.js 15 error.tsx, notFound(), and the Patterns I Actually Use

For a while, every unhandled error in my Next.js apps meant one thing: the whole page went white.

One failed fetch, one bad database query, and the user got a blank screen or a generic Next.js error page — no matter how small the broken piece actually was.

The App Router gives you file-based error boundaries that fix this properly. Here's how I use them now.


1. error.tsx — Route-Level Error Boundaries

Any folder in app/ can have an error.tsx. It automatically wraps everything below it in an error boundary — no manual <ErrorBoundary> wrapping required:

// app/dashboard/error.tsx
'use client';

export default function DashboardError({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  return (
    <div className="p-8 text-center">
      <h2 className="text-lg font-semibold">Something went wrong</h2>
      <p className="text-sm text-slate-500 mt-2">{error.message}</p>
      <button
        onClick={reset}
        className="mt-4 px-4 py-2 rounded-lg bg-brand-500 text-white"
      >
        Try again
      </button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

error.tsx must be a Client Component — it needs reset(), which re-renders the segment and attempts recovery. If /dashboard/analytics throws, only that segment shows this fallback. The rest of the dashboard — sidebar, nav, other pages — keeps working.


2. Nested Error Boundaries — Isolate the Blast Radius

This is the part people miss: error boundaries nest. Put one closer to the risky code, and a failure there won't take out the parent route at all.

app/
├── dashboard/
│   ├── layout.tsx
│   ├── error.tsx          ← catches errors anywhere in dashboard
│   └── analytics/
│       ├── page.tsx
│       └── error.tsx      ← catches errors only in analytics
Enter fullscreen mode Exit fullscreen mode
// app/dashboard/analytics/error.tsx
'use client';

export default function AnalyticsError({ reset }: { reset: () => void }) {
  return (
    <div className="rounded-lg border border-red-500/20 p-4">
      <p>Analytics failed to load.</p>
      <button onClick={reset}>Retry</button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

If the analytics panel throws, the sidebar and the rest of the dashboard stay fully interactive — only the analytics section shows the fallback. Without this, one flaky third-party analytics API can take down a page that has nothing to do with it.


3. global-error.tsx — The Last Resort

For errors in the root layout itself — where even your normal error boundaries can't help, since they live inside that layout:

// app/global-error.tsx
'use client';

export default function GlobalError({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  return (
    <html>
      <body>
        <div className="p-8 text-center">
          <h1>Something went wrong</h1>
          <button onClick={reset}>Try again</button>
        </div>
      </body>
    </html>
  );
}
Enter fullscreen mode Exit fullscreen mode

This one replaces the entire root layout, including <html> and <body> — it's the only case where that's necessary, and it's the fallback of last resort, not something you reach for often.


4. not-found.tsx and notFound()

For missing data — a blog post that doesn't exist, a user ID that isn't in the database — that's not really an "error," it's a 404:

// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation';

export default async function BlogPost({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const post = await getPost(slug);

  if (!post) {
    notFound();
  }

  return <article>{post.content}</article>;
}
Enter fullscreen mode Exit fullscreen mode
// app/blog/[slug]/not-found.tsx
export default function PostNotFound() {
  return (
    <div className="text-center py-20">
      <h2>Post not found</h2>
      <p>The post you're looking for doesn't exist or was removed.</p>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

notFound() isn't an error — it's a signal. It stops rendering and shows the nearest not-found.tsx, with a proper 404 status code, instead of you having to manually build that logic into every page that fetches by ID or slug.


5. loading.tsx — Instant Loading States for Free

Pair with error boundaries for the full picture. Any segment can have a loading.tsx, and Next.js shows it automatically while the async Server Component fetches:

// app/dashboard/analytics/loading.tsx
export default function AnalyticsLoading() {
  return (
    <div className="animate-pulse space-y-4">
      <div className="h-8 w-48 bg-surface-700 rounded" />
      <div className="h-64 bg-surface-700 rounded-lg" />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

No useState, no manual isLoading flag — Next.js wraps the segment in Suspense for you and swaps loading.tsx in the instant navigation starts, then swaps the real content in once the data resolves.


6. try/catch Still Has a Job — Inside the Fetch, Not Around It

error.tsx catches what escapes the render. It won't catch a fetch that fails silently and returns undefined instead of throwing. Handle that at the source:

// lib/queries/posts.ts
export async function getPost(slug: string) {
  try {
    const res = await fetch(`https://api.example.com/posts/${slug}`);
    if (!res.ok) return null;
    return res.json();
  } catch (err) {
    console.error('Failed to fetch post:', err);
    return null;
  }
}
Enter fullscreen mode Exit fullscreen mode

Returning null on failure and handling it explicitly (if (!post) notFound()) gives you control over whether a fetch failure means "404" or "error page" — throwing indiscriminately from inside a query helper takes that decision away from the caller.


7. Server Action Errors Don't Need error.tsx at All

Server Actions that return a typed result instead of throwing keep failures in the UI, not in an error boundary:

// actions/posts.ts
'use server';

export async function deletePost(id: string) {
  try {
    await db.posts.delete({ where: { id } });
    revalidatePath('/dashboard/posts');
    return { success: true };
  } catch {
    return { success: false, message: 'Could not delete post' };
  }
}
Enter fullscreen mode Exit fullscreen mode

A failed delete shouldn't send the user to a full error boundary — it should show an inline "couldn't delete that, try again" message right next to the button they clicked. Catch it, return it, render it.


Summary

File / Pattern Handles
error.tsx Unexpected render/fetch errors in a route segment
Nested error.tsx Isolates failures to the smallest segment possible
global-error.tsx Errors in the root layout itself (rare, last resort)
not-found.tsx + notFound() Missing data — not an error, a 404
loading.tsx Automatic loading UI while a segment fetches
try/catch in query helpers Turning a failed fetch into a controlled null, not a crash
Typed return in Server Actions Inline failure messages instead of a full error boundary

The shift that matters most: stop thinking of "error handling" as one big try/catch around everything, and start thinking about which segment should fail without taking the rest of the page with it.

I use this exact error/loading/not-found setup across every route in my dashboards and templates.

See it in a real codebase:

Get the templates: https://pixelanas.gumroad.com

Do you lean on error.tsx or handle most failures with try/catch and typed returns? Drop it below 👇


Anas — full-stack Next.js developer building SaaS products and premium templates. X: @ASheikh69751

Top comments (0)