React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

loadingerrornot-foundsuspenseerror-boundarystreaming

Loading & Error Handling

Special files loading.tsx, error.tsx, and not-found.tsx create automatic Suspense boundaries and Error Boundaries scoped to route segments.

Recipe

Quick-reference recipe card — copy-paste ready.

app/
├── loading.tsx       # Global loading fallback
├── error.tsx         # Global error boundary
├── not-found.tsx     # Global 404
└── dashboard/
    ├── loading.tsx   # Loading fallback for /dashboard
    ├── error.tsx     # Error boundary for /dashboard
    └── page.tsx
// app/dashboard/loading.tsx
export default function Loading() {
  return <div className="animate-pulse">Loading dashboard...</div>;
}
 
// app/dashboard/error.tsx
"use client";
export default function Error({ error, reset }: { error: Error; reset: () => void }) {
  return (
    <div>
      <h2>Something went wrong</h2>
      <button onClick={reset}>Try again</button>
    </div>
  );
}

When to reach for this: Every route segment that fetches data should have a loading.tsx. Every segment where errors are recoverable should have an error.tsx.

Working Example

// app/dashboard/loading.tsx — Skeleton loading state
export default function DashboardLoading() {
  return (
    <div className="space-y-4 p-6">
      <div className="h-8 w-48 animate-pulse rounded bg-gray-200" />
      <div className="grid grid-cols-3 gap-4">
        {Array.from({ length: 3 }).map((_, i) => (
          <div key={i} className="h-32 animate-pulse rounded-lg bg-gray-200" />
        ))}
      </div>
      <div className="h-64 animate-pulse rounded-lg bg-gray-200" />
    </div>
  );
}
// app/dashboard/error.tsx — Must be a Client Component
"use client";
 
import { useEffect } from "react";
 
export default function DashboardError({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  useEffect(() => {
    // Log error to monitoring service
    console.error("Dashboard error:", error);
  }, [error]);
 
  return (
    <div className="flex flex-col items-center justify-center p-12">
      <h2 className="text-xl font-bold text-red-600">Dashboard Error</h2>
      <p className="mt-2 text-gray-600">
        {error.message || "An unexpected error occurred."}
      </p>
      {error.digest && (
        <p className="mt-1 text-sm text-gray-400">Error ID: {error.digest}</p>
      )}
      <button
        onClick={reset}
        className="mt-4 rounded bg-blue-600 px-4 py-2 text-white hover:bg-blue-700"
      >
        Try Again
      </button>
    </div>
  );
}
// app/dashboard/page.tsx — Server Component that might fail
import { notFound } from "next/navigation";
 
async function getDashboardData() {
  const res = await fetch("https://api.example.com/dashboard", {
    next: { revalidate: 60 },
  });
  if (res.status === 404) notFound();
  if (!res.ok) throw new Error("Failed to load dashboard data");
  return res.json();
}
 
export default async function DashboardPage() {
  const data = await getDashboardData();
 
  return (
    <div className="p-6">
      <h1 className="text-2xl font-bold">Dashboard</h1>
      <div className="mt-4 grid grid-cols-3 gap-4">
        <StatCard label="Revenue" value={data.revenue} />
        <StatCard label="Users" value={data.users} />
        <StatCard label="Orders" value={data.orders} />
      </div>
    </div>
  );
}
 
function StatCard({ label, value }: { label: string; value: number }) {
  return (
    <div className="rounded-lg border p-4">
      <p className="text-sm text-gray-500">{label}</p>
      <p className="text-2xl font-bold">{value.toLocaleString()}</p>
    </div>
  );
}
// app/not-found.tsx — Custom 404 page
import Link from "next/link";
 
export default function NotFound() {
  return (
    <div className="flex min-h-screen flex-col items-center justify-center">
      <h1 className="text-6xl font-bold">404</h1>
      <p className="mt-4 text-xl text-gray-600">Page not found</p>
      <Link
        href="/"
        className="mt-6 rounded bg-blue-600 px-6 py-2 text-white hover:bg-blue-700"
      >
        Go Home
      </Link>
    </div>
  );
}

Deep Dive

How It Works

  • loading.tsx wraps the page in a <Suspense> boundary. Next.js automatically generates <Suspense fallback={<Loading />}><Page /></Suspense>. The loading UI shows instantly while the page streams in.
  • error.tsx wraps the page in a React Error Boundary. It catches JavaScript errors in the page and its children during rendering. The reset function re-renders the error boundary contents.
  • error.tsx must be a Client Component. Error Boundaries are a client-side React feature. Always add "use client" at the top.
  • error.tsx does not catch errors in the same-level layout. The error boundary sits between the layout and the page. To catch layout errors, place error.tsx in the parent segment.
  • not-found.tsx triggers on notFound() calls or unmatched routes. The root not-found.tsx is the fallback for all unmatched URLs. Nested not-found.tsx only activates when you explicitly call notFound().
  • Loading and error states are per-segment. Each folder can have its own loading and error files, giving fine-grained control over which parts of the UI show placeholders or error states.
  • Streaming works with loading.tsx. The layout renders immediately, the loading fallback shows, and the page content streams in when ready.

Variations

// Nested loading — only the innermost segment shows loading
// app/dashboard/settings/loading.tsx
export default function SettingsLoading() {
  return <p>Loading settings...</p>;
  // The dashboard layout and sidebar remain visible
}
// Global error boundary — catches app-wide errors
// app/global-error.tsx
"use client";
 
export default function GlobalError({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  return (
    <html>
      <body>
        <h1>Something went very wrong</h1>
        <button onClick={reset}>Try again</button>
      </body>
    </html>
  );
}
// Note: global-error.tsx replaces the root layout, so it must include <html> and <body>
// Manual Suspense boundaries for granular loading
// app/dashboard/page.tsx
import { Suspense } from "react";
 
export default function DashboardPage() {
  return (
    <div className="grid grid-cols-2 gap-4">
      <Suspense fallback={<Skeleton />}>
        <RevenueChart />
      </Suspense>
      <Suspense fallback={<Skeleton />}>
        <UserTable />
      </Suspense>
    </div>
  );
}
 
async function RevenueChart() {
  const data = await getRevenue(); // streams independently
  return <div>{/* chart */}</div>;
}
 
async function UserTable() {
  const users = await getUsers(); // streams independently
  return <table>{/* rows */}</table>;
}
 
function Skeleton() {
  return <div className="h-48 animate-pulse rounded bg-gray-200" />;
}

TypeScript Notes

// error.tsx props type
interface ErrorProps {
  error: Error & { digest?: string }; // digest is a server-side error hash
  reset: () => void;                  // re-renders the error boundary
}
 
// loading.tsx and not-found.tsx take no props
// They are simple components with no parameters
 
// global-error.tsx has the same props as error.tsx
// but must render its own <html> and <body>

Gotchas

  • error.tsx must have "use client". Forgetting this causes a build error. Error Boundaries are inherently client-side.
  • error.tsx cannot catch errors in its sibling layout.tsx. The boundary wraps the page, not the layout. Use a parent error.tsx to catch layout errors.
  • global-error.tsx only activates in production. In development, the Next.js error overlay appears instead.
  • global-error.tsx must include <html> and <body>. It replaces the root layout entirely when triggered.
  • notFound() from next/navigation throws. Code after notFound() is unreachable. TypeScript may not warn about this.
  • loading.tsx shows on initial load and subsequent navigations. It appears every time the segment's page component is pending.
  • reset() only works for client-side errors. If a Server Component throws, reset() re-attempts rendering but the same server error may recur without a code fix or data change.

Alternatives

ApproachWhen to Use
Manual <Suspense> boundaryGranular loading states within a single page
React Error Boundary libraryCustom error handling in Client Components
try/catch in Server ComponentsHandling errors without showing the error boundary
Redirect on errorSending users to a different page instead of showing an error UI
unstable_rethrowRe-throwing internal Next.js errors (redirects, notFound) from catch blocks

FAQs

Why must error.tsx be a Client Component?

Error Boundaries are a client-side React feature. They rely on componentDidCatch lifecycle methods which only exist on the client. You must add "use client" at the top of every error.tsx file.

Gotcha: Can error.tsx catch errors thrown in its sibling layout.tsx?

No. The error boundary wraps the page, not the layout at the same level. To catch layout errors, place error.tsx in the parent segment.

What is the difference between error.tsx and global-error.tsx?
  • error.tsx catches errors within a specific route segment
  • global-error.tsx catches app-wide errors, including root layout errors
  • global-error.tsx must include its own <html> and <body> because it replaces the root layout
  • global-error.tsx only activates in production; in dev, the Next.js error overlay appears instead
How does loading.tsx create a Suspense boundary automatically?

Next.js generates <Suspense fallback={<Loading />}><Page /></Suspense> under the hood. The loading UI renders instantly while the page content streams in.

Does loading.tsx show on every navigation or only the first load?

It shows on initial load and on every subsequent navigation to that segment. Any time the page component is pending, the loading fallback appears.

When does not-found.tsx trigger automatically vs. when must you call notFound()?
  • Root not-found.tsx triggers automatically for all unmatched URLs
  • Nested not-found.tsx only activates when you explicitly call notFound() from next/navigation
What does the reset function in error.tsx actually do?

It re-renders the error boundary's contents, attempting to render the page again. For client-side errors this retries rendering. For server errors, the same error may recur without a data or code change.

How do you use manual Suspense boundaries for more granular loading states?
import { Suspense } from "react";
 
export default function Page() {
  return (
    <div className="grid grid-cols-2 gap-4">
      <Suspense fallback={<Skeleton />}>
        <RevenueChart />
      </Suspense>
      <Suspense fallback={<Skeleton />}>
        <UserTable />
      </Suspense>
    </div>
  );
}

Each async component streams independently.

What is the TypeScript type for error.tsx props?
interface ErrorProps {
  error: Error & { digest?: string };
  reset: () => void;
}

The digest property is a server-side error hash useful for logging.

Gotcha: What happens if you call notFound() inside a try/catch block?

notFound() throws internally, so the catch block will intercept it. Use unstable_rethrow in catch blocks to re-throw internal Next.js errors like notFound() and redirect().

Do loading.tsx and not-found.tsx accept any props?

No. Both are simple components with no parameters. They receive no props from Next.js.

How do per-segment loading and error states help with streaming?
  • The layout renders immediately
  • Each segment's loading.tsx shows a fallback for its portion of the page
  • As each segment's data resolves, it streams in and replaces its loading fallback
  • An error in one segment does not block other segments from rendering