Error Handling & Loading UI in Next.js

1. Introduction 🚀

Things will fail — a database times out, an API returns garbage, a user hits a route that doesn't exist. What separates a polished app from a broken one is how gracefully it handles those moments. This tutorial covers the full lifecycle: loading.tsx while data is in flight, error.tsx when something breaks, and not-found.tsx when a route simply isn't there.

Information

Nearly every convention in this guide is a special file the app router recognizes automatically by name and location — no manual wiring required.

2. Understanding Errors in Next.js 🤔

Errors in the app router fall into a few categories, each handled by a different mechanism: rendering errors (caught by error.tsx), missing routes (not-found.tsx), and errors you catch and handle manually inside Route Handlers or Server Actions.

3. Types of Errors 🧭

TypeExampleHandled by
Rendering errorA component throws during rendererror.tsx
Not foundUser visits a non-existent post IDnot-found.tsx
Root layout errorError in the root layout itselfglobal-error.tsx
API/data errorA fetch or database call failsManual try/catch

4. Error Boundaries đŸ›Ąī¸

Next.js automatically wraps each route segment in a React error boundary when an error.tsx file is present, catching any error thrown during rendering in that segment or its children.

Tip

Error boundaries only catch errors during rendering — they do not catch errors from event handlers, which you should handle with a normal try/catch.

5. error.tsx đŸ§¯

An error.tsx file defines the fallback UI for its route segment. It must be a Client Component, since it uses React state and an event handler to trigger recovery.

app/dashboard/error.tsx

'use client';

export default function Error({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
  return (
    <div>
      <h2>Something went wrong!</h2>
      <p>{error.message}</p>
      <button onClick={() => reset()}>Try again</button>
    </div>
  );
}

Warning

The error.tsx component must include 'use client' — Next.js will throw a build error otherwise, since it relies on the reset() callback and error boundary behavior.

6. global-error.tsx 🌐

global-error.tsx catches errors thrown in the root layout itself — a place a regular error.tsx can't reach, since it lives inside the layout it would need to replace.

app/global-error.tsx

'use client';

export default function GlobalError({ error, reset }: { error: Error; reset: () => void }) {
  return (
    <html>
      <body>
        <h2>A critical error occurred</h2>
        <button onClick={() => reset()}>Try again</button>
      </body>
    </html>
  );
}

Important

global-error.tsx replaces the entire root layout, including <html> and <body> tags — it must define them itself.

7. not-found.tsx 🔍

A not-found.tsx file renders whenever the notFound() function is called, or when a URL doesn't match any route.

app/blog/[slug]/not-found.tsx

export default function NotFound() {
  return (
    <div>
      <h2>Post not found</h2>
      <p>The post you're looking for doesn't exist.</p>
    </div>
  );
}

app/blog/[slug]/page.tsx

import { notFound } from 'next/navigation';

export default async function PostPage({ params }: { params: { slug: string } }) {
  const post = await getPost(params.slug);
  if (!post) notFound();
  return <Article post={post} />;
}

8. loading.tsx âŗ

A loading.tsx file automatically wraps its route segment (and any nested segments) in a Suspense boundary, showing instantly while the async page component resolves.

app/dashboard/loading.tsx

export default function Loading() {
  return <p>Loading dashboard...</p>;
}

9. Loading UI đŸ–ŧī¸

Good loading UI should roughly match the shape of the content it's replacing, so the page doesn't visually "jump" once real content arrives.

Best Practice

Match your loading.tsx's layout dimensions to the real content as closely as possible — this avoids CLS when the actual page swaps in.

10. Skeleton Screens 💀

A skeleton screen shows gray placeholder blocks in the shape of the eventual content — generally perceived as faster and less jarring than a plain spinner.

app/dashboard/loading.tsx

export default function Loading() {
  return (
    <div>
      <div className="h-6 w-1/3 animate-pulse rounded bg-gray-200" />
      <div className="mt-2 h-4 w-full animate-pulse rounded bg-gray-200" />
    </div>
  );
}

11. Suspense Boundaries â¸ī¸

For more granular control than a page-level loading.tsx, wrap individual slow components in their own Suspense boundary, letting fast parts of the page render immediately.

app/dashboard/page.tsx

import { Suspense } from 'react';

export default function Dashboard() {
  return (
    <div>
      <Header />
      <Suspense fallback={<StatsSkeleton />}>
        <SlowStats />
      </Suspense>
    </div>
  );
}

12. Async Error Handling âš ī¸

An error thrown inside an async Server Component is automatically caught by the nearest error.tsx boundary — no manual try/catch is required at that layer.

app/dashboard/page.tsx

export default async function Dashboard() {
  const data = await fetchDashboardData(); // if this throws, error.tsx catches it
  return <DashboardView data={data} />;
}

13. Route Handler Errors 🌐

Unlike page rendering, Route Handlers have no automatic error boundary — an uncaught error results in a generic 500 response. Always wrap logic in try/catch.

app/api/users/route.ts

export async function GET() {
  try {
    const users = await db.user.findMany();
    return Response.json(users);
  } catch (error) {
    console.error(error);
    return Response.json({ error: 'Failed to fetch users' }, { status: 500 });
  }
}

14. Server Action Errors đŸŽŦ

Errors thrown inside a Server Action bubble up to the nearest error.tsx if called directly during render, but when called from a form submission, it's usually clearer to return a structured error object instead.

app/actions.ts

'use server';

export async function createPost(formData: FormData) {
  const title = formData.get('title');
  if (!title) {
    return { error: 'Title is required' };
  }
  try {
    await db.post.create({ data: { title: title.toString() } });
    return { success: true };
  } catch {
    return { error: 'Failed to create post' };
  }
}

15. API Error Handling 📡

When calling an external API with fetch, remember that fetch does not throw on a 4xx/5xx response — you must check response.ok yourself.

lib/data.ts

export async function getUser(id: string) {
  const res = await fetch(`https://api.example.com/users/${id}`);
  if (!res.ok) {
    throw new Error(`Failed to fetch user: ${res.status}`);
  }
  return res.json();
}

16. Data Fetching Errors 🔌

When a data source fails, decide deliberately between three outcomes: throw (triggering error.tsx), call notFound() (if the data simply doesn't exist), or return a fallback value (if partial content is acceptable).

17. Validation Errors ✅

Validation errors — a malformed email, a missing required field — should generally be returned as data, not thrown as exceptions, so they can be displayed inline next to the relevant form field.

app/actions.ts

'use server';
import { z } from 'zod';

const schema = z.object({ email: z.string().email() });

export async function subscribe(formData: FormData) {
  const result = schema.safeParse({ email: formData.get('email') });
  if (!result.success) {
    return { error: result.error.flatten().fieldErrors };
  }
  await addSubscriber(result.data.email);
  return { success: true };
}

18. Authentication Errors 🔐

An unauthenticated request should typically redirect to a login page (for page views) or return a 401 JSON response (for API calls) — the right choice depends on who is calling.

app/dashboard/page.tsx

import { redirect } from 'next/navigation';
import { getSession } from '@/lib/session';

export default async function Dashboard() {
  const session = await getSession();
  if (!session) redirect('/login');
  return <DashboardView user={session.user} />;
}

19. Network Errors đŸ“ļ

A failed network request — a timeout, a DNS failure — should be caught and surfaced as a clear, human-readable message rather than a raw stack trace or a blank page.

lib/data.ts

export async function safeFetch(url: string) {
  try {
    const res = await fetch(url);
    if (!res.ok) throw new Error(`Request failed: ${res.status}`);
    return await res.json();
  } catch (err) {
    throw new Error('Unable to reach the server. Please try again.');
  }
}

20. Redirect Errors â†Ēī¸

Calling redirect() from next/navigation works by internally throwing a special error that Next.js recognizes — never wrap a call to redirect() in a try/catch that swallows it.

Caution

If you must use try/catch around code that calls redirect(), re-throw the error, or check isRedirectError() from next/dist/client/components/redirect before handling it.

21. Custom Error Pages 🎨

Beyond the default fallback text, a well-designed error.tsx or not-found.tsx should match your app's branding and offer a clear next step — a link home, a retry button, a support contact.

app/not-found.tsx

import Link from 'next/link';

export default function NotFound() {
  return (
    <div>
      <h1>404 — Page Not Found</h1>
      <p>We couldn't find what you were looking for.</p>
      <Link href="/">Return home</Link>
    </div>
  );
}

22. Logging Errors 📝

Inside error.tsx, use a useEffect to log the error to a monitoring service as soon as the boundary renders, so failures are captured even if the user never reports them.

app/error.tsx

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

export default function Error({ error, reset }: { error: Error; reset: () => void }) {
  useEffect(() => {
    logErrorToService(error);
  }, [error]);

  return <p>Something went wrong.</p>;
}

23. Monitoring Errors 📈

Tools like Sentry or Datadog capture errors from both the client and server in production, with stack traces, breadcrumbs, and alerting — far more actionable than console.log alone.

24. Debugging 🐞

  1. Reproduce the error locally with next dev, where full stack traces are shown.
  2. Check whether the error originates in a Server Component, Client Component, Route Handler, or Server Action.
  3. Add targeted logging around the suspected failure point.
  4. Confirm the fix by triggering the exact same code path that failed originally.

25. Development vs Production Errors 🔀

In development, Next.js shows a detailed overlay with the full stack trace and source location. In production, that detail is hidden from the user by default — only a generic message and a digest ID are shown, to avoid leaking internals.

Warning

Never display error.message directly to end users in production if it might contain internal details like a database query or file path — log the full error server-side and show a generic message instead.

26. Graceful Recovery 🔄

The reset() function passed to error.tsx attempts to re-render the segment without a full page reload — ideal for transient errors like a momentary network blip.

app/error.tsx

'use client';

export default function Error({ reset }: { reset: () => void }) {
  return (
    <div>
      <p>Something went wrong.</p>
      <button onClick={() => reset()}>Try again</button>
    </div>
  );
}

27. User Experience Best Practices ✨

  • Always give the user a next step — retry, go home, contact support.
  • Keep error messages calm and non-technical; save the stack trace for your logs.
  • Match loading skeletons closely to real content to avoid layout shift.
  • Distinguish "not found" from "something broke" — they call for different messaging.

28. Common Mistakes đŸšĢ

Common Error Handling Mistakes
Forgetting 'use client' on error.tsx
Not wrapping Route Handler logic in try/catch, leaking a raw 500
Redirect mishandling
Showing raw error messages or stack traces directly to end users
Swallowing the special error thrown by redirect() inside a broad try/catch
Calling notFound() when a real error occurred, hiding the actual failure

29. Frequently Asked Questions ❓

No — error boundaries only catch errors thrown during rendering. Errors inside onClick or other event handlers need their own try/catch.

error.tsx handles unexpected failures (something broke), while not-found.tsx handles the expected case of a resource simply not existing.

Yes — you can place a loading.tsx in any route segment, and each one automatically wraps just that segment and its children in a Suspense boundary.

30. Summary 📚