Loading & Error Handling
Special files loading.tsx, error.tsx, and not-found.tsx create automatic Suspense boundaries and Error Boundaries scoped to route segments.
Search across all documentation pages
Special files loading.tsx, error.tsx, and not-found.tsx create automatic Suspense boundaries and Error Boundaries scoped to route segments.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
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.
// 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>
);
}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.tsx. The layout renders immediately, the loading fallback shows, and the page content streams in when ready.// 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" />;
}// 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>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.| Approach | When to Use |
|---|---|
Manual <Suspense> boundary | Granular loading states within a single page |
| React Error Boundary library | Custom error handling in Client Components |
try/catch in Server Components | Handling errors without showing the error boundary |
| Redirect on error | Sending users to a different page instead of showing an error UI |
unstable_rethrow | Re-throwing internal Next.js errors (redirects, notFound) from catch blocks |
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.
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.
error.tsx catches errors within a specific route segmentglobal-error.tsx catches app-wide errors, including root layout errorsglobal-error.tsx must include its own <html> and <body> because it replaces the root layoutglobal-error.tsx only activates in production; in dev, the Next.js error overlay appears insteadNext.js generates <Suspense fallback={<Loading />}><Page /></Suspense> under the hood. The loading UI renders instantly while the page content streams in.
It shows on initial load and on every subsequent navigation to that segment. Any time the page component is pending, the loading fallback appears.
not-found.tsx triggers automatically for all unmatched URLsnot-found.tsx only activates when you explicitly call notFound() from next/navigationIt 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.
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.
interface ErrorProps {
error: Error & { digest?: string };
reset: () => void;
}The digest property is a server-side error hash useful for logging.
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().
No. Both are simple components with no parameters. They receive no props from Next.js.
loading.tsx shows a fallback for its portion of the pagenotFound() and redirect() functionsReviewed by Chris St. John·Last updated Jul 10, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥