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.tsxwraps 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.tsxwraps the page in a React Error Boundary. It catches JavaScript errors in the page and its children during rendering. Theresetfunction re-renders the error boundary contents.error.tsxmust be a Client Component. Error Boundaries are a client-side React feature. Always add"use client"at the top.error.tsxdoes not catch errors in the same-level layout. The error boundary sits between the layout and the page. To catch layout errors, placeerror.tsxin the parent segment.not-found.tsxtriggers onnotFound()calls or unmatched routes. The rootnot-found.tsxis the fallback for all unmatched URLs. Nestednot-found.tsxonly activates when you explicitly callnotFound().- 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.tsxmust have"use client". Forgetting this causes a build error. Error Boundaries are inherently client-side.error.tsxcannot catch errors in its siblinglayout.tsx. The boundary wraps the page, not the layout. Use a parenterror.tsxto catch layout errors.global-error.tsxonly activates in production. In development, the Next.js error overlay appears instead.global-error.tsxmust include<html>and<body>. It replaces the root layout entirely when triggered.notFound()fromnext/navigationthrows. Code afternotFound()is unreachable. TypeScript may not warn about this.loading.tsxshows 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
| 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 |
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.tsxcatches errors within a specific route segmentglobal-error.tsxcatches app-wide errors, including root layout errorsglobal-error.tsxmust include its own<html>and<body>because it replaces the root layoutglobal-error.tsxonly 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.tsxtriggers automatically for all unmatched URLs - Nested
not-found.tsxonly activates when you explicitly callnotFound()fromnext/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.tsxshows 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
Related
- App Router Basics — file conventions overview
- Layouts — how error boundaries interact with layouts
- Navigation —
notFound()andredirect()functions - Parallel Routes — independent error states per slot