Loading & Error UI, the Nesting Rules
Understand how loading.tsx, error.tsx, not-found, and global-error compose across nested segments — and what each one can and cannot catch.
The convenience files (loading, error, not-found) are simple to add but their composition across a nested tree is where seniors earn their keep.
loading.tsx is just a Suspense boundary
Dropping a loading.tsx into a segment wraps that segment's page (and everything below it) in <Suspense fallback={<Loading />}>. It shows instantly on navigation while the async server work resolves. A deeper loading.tsx overrides a shallower one for its sub-tree.
error.tsx catches children, not siblings
This is the rule people forget: an error.tsx is an error boundary around its children. It cannot catch an error thrown by the layout.tsx in the same folder, because that layout sits above the boundary. To catch layout-level errors, put the boundary one level up. Error components must be Client Components and receive { error, reset }.
The escalation ladder
not-found.tsx— fornotFound()and unmatched URLs (a 404, an expected empty state).error.tsx— for unexpected thrown errors in a segment; offersreset()to retry.global-error.tsx— the last resort; it replaces the root layout, so it must render its own<html>and<body>. It only fires in production.
Example
// app/dashboard/error.tsx
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
export default function DashboardError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
const router = useRouter();
useEffect(() => {
// Ship to your error tracker; digest correlates with server logs
reportError(error, { digest: error.digest });
}, [error]);
return (
<div role="alert">
<h2>Could not load the dashboard</h2>
<button
onClick={() => {
router.refresh(); // re-run server data
reset(); // then re-render the segment
}}
>
Try again
</button>
</div>
);
}When to use it
- A nested route error in a dashboard widget is caught by the widget's error.tsx rather than crashing the whole dashboard.
- A global-error.tsx at the app root catches errors thrown inside the root layout that a segment error.tsx cannot reach.
- A segment's not-found.tsx displays a context-aware 404 message for missing blog posts without affecting the global 404 page.
More examples
Error boundary nesting rules
An error.tsx catches errors from its sibling page.tsx and child segments but cannot catch its sibling layout.tsx.
app/
├── error.tsx # catches errors from app/page.tsx
└── dashboard/
├── error.tsx # catches errors from dashboard/page.tsx
│ # (NOT from dashboard/layout.tsx)
├── layout.tsx
└── page.tsxglobal-error.tsx for root layout
global-error.tsx must include html and body because it replaces the root layout when an error occurs there.
// app/global-error.tsx
'use client';
export default function GlobalError({ error, reset }: { error: Error; reset: () => void }) {
return (
<html>
<body>
<h1>Something went wrong</h1>
<button onClick={reset}>Try again</button>
</body>
</html>
);
}Scoped not-found for a segment
Placing not-found.tsx inside [slug] gives missing posts a context-aware 404 that is different from the global one.
// app/blog/[slug]/not-found.tsx
import Link from 'next/link';
export default function BlogNotFound() {
return (
<>
<h2>Post not found</h2>
<Link href="/blog">Back to all posts</Link>
</>
);
}
Discussion