Concept
The beginner framing: loading.tsx shows a fallback while a route segment's content is being fetched; error.tsx shows a fallback UI if something throws while rendering that segment.
The precise mental model: neither of these introduces a new mechanism, both are Next.js automatically wiring up React primitives you already know at the route-segment level. loading.tsx wraps page.tsx (and everything nested below it) in a <Suspense> boundary (see Suspense). error.tsx wraps the same scope in a React error boundary (see Error Boundaries), which is why error.tsx must be a Client Component, since error boundaries require the class-component lifecycle methods only available there.
// app/dashboard/loading.tsx
export default function Loading() {
return <DashboardSkeleton />;
}// app/dashboard/error.tsx
"use client"; // error boundaries must be Client Components
export default function Error({
error,
unstable_retry,
}: {
error: Error & { digest?: string };
unstable_retry: () => void;
}) {
return (
<div>
<h2>Something went wrong!</h2>
<button onClick={() => unstable_retry()}>Try again</button>
</div>
);
What exactly gets wrapped
In the component hierarchy for a single route segment, error.tsx wraps loading.tsx, not-found.tsx, page.tsx, and any nested layout.tsx below it, but it does not wrap the layout.tsx/template.tsx at the same level, above it. This asymmetry matters: an error thrown by the shared layout wrapping a page is NOT caught by that page's own error.tsx, it bubbles further up, to the nearest error boundary above that layout.
unstable_retry vs. reset (a genuine Next 16 change)
As of v16.2.0, error.tsx receives an unstable_retry function in addition to the older reset. They are not interchangeable:
unstable_retry() | reset() | |
|---|---|---|
| Behavior | Re-fetches AND re-renders the boundary's children | Only clears the error state and re-renders with existing data |
| Recommended for | Most cases, actually attempts recovery | Rare: when you deliberately don't want to re-fetch |
The docs are direct about this: "In most cases, you should use unstable_retry() instead" of reset().
not-found.tsx and global errors
// app/blog/[slug]/page.tsx
import { notFound } from "next/navigation";
export default async function Page({ params }: PageProps<"/blog/[slug]">) {
const { slug } = await params;
const post = await getPostBySlug(slug);
if (!post) notFound(); // renders the nearest not-found.tsx
return <article>{post.title}</article>;
}For errors in the root layout itself, which no ordinary error.tsx can catch, since it can't wrap something above it, use app/global-error.tsx, which must define its own <html> and <body> tags, since it replaces the entire root layout when active.
Component-level error recovery: unstable_catchError
For error boundaries that aren't tied to a whole route segment, wrapping just one risky widget, say, unstable_catchError (from next/error) builds a reusable boundary component you can place anywhere:
"use client";
import { unstable_catchError as catchError } from "next/error";
function ErrorFallback(props: { title: string }, { error, unstable_retry }: ErrorInfo) {
return (
<div>
<h2>{props.title}</h2>
<button onClick={() => unstable_retry()}>Try again</button>
</div>
);
}
export default catchErrorTry It
Predict what happens before checking the solution.
app/
dashboard/
layout.tsx (throws an error while rendering)
error.tsx (an error boundary for this segment)
page.tsxIf app/dashboard/layout.tsx itself throws during render, does app/dashboard/error.tsx catch it?
Solution
No. error.tsx wraps page.tsx and any NESTED layouts below it, but explicitly does not wrap the layout at the same segment level, above it. An error thrown by app/dashboard/layout.tsx bubbles up past app/dashboard/error.tsx entirely, looking for the next error boundary further up the tree (a parent segment's error.tsx, or ultimately global-error.tsx if nothing else catches it).
Implement It Yourself
Model the nesting rule that determines what error.tsx actually wraps:
function buildErrorBoundaryScope(segment) {
// error.tsx wraps: loading, not-found, page, and NESTED layouts, // but explicitly excludes this SAME segment's own layout/template.
return {
wraps: ["loading", "not-found", "page", "nested-layouts"],
excludes: ["own-layout", "own-template"],
};
}
function findErrorBoundary(throwingComponent, segmentTree) {
let segment = throwingComponent.segment;
// if the throw came from THIS segment's own layout, skip THIS segment's
// error.tsx and look at the PARENT segment instead
if (throwingComponent.type === "layout" && throwingComponent.segment === segment) {
segment = segment.parent;
This mirrors the real rule: a thrown layout doesn't get caught by its own segment's error.tsx, the search for a catching boundary always starts one level higher when the throw comes from that segment's own layout.
Under the Hood
Both special files are thin Next.js conventions over React primitives already covered: loading.tsx is Suspense's boundary-and-fallback mechanism, applied automatically per route segment; error.tsx is exactly the class-component Error Boundary pattern, getDerivedStateFromError and componentDidCatch, with Next.js generating that boundary for you and injecting error/unstable_retry as props. The requirement that error.tsx be a Client Component is the same "error boundaries must currently be classes, and classes need client-side interactivity to matter here" constraint from that same topic.
Common Mistakes
1. Forgetting error.tsx must be a Client Component
// app/dashboard/error.tsx
export default function Error({ error }: { error: Error }) { // ❌ missing "use client"
return <div>{error.message}</div>;
}Error boundaries require class-component lifecycle methods, which only exist in the client runtime, omitting "use client" causes a build error.
2. Using reset() where unstable_retry() was actually needed
reset() only clears the error state and re-renders with whatever data was already there, it does not re-fetch. If the error was caused by stale or failed data, reset() alone won't fix anything; unstable_retry(), which re-fetches and re-renders, is what the docs now recommend for "most cases."
3. Expecting error.tsx to catch errors from its own segment's layout
Covered in Try It, the asymmetry (wraps nested layouts, not its own) is the single most surprising rule in this topic and worth internalizing explicitly.
4. Trying to catch event handler or async errors with error.tsx
<button onClick={() => { throw new Error("boom"); }}>Click</button>
// ❌ error.tsx does NOT catch this, it only catches errors during renderingExactly as with error boundaries generally, event handler and most async errors need an ordinary try/catch plus local state, not a route-level error boundary.
Best Practices
- Reach for
unstable_retry()overreset()in nearly all cases, it's the one that actually attempts to recover by re-fetching, which is what "Try again" almost always should mean to a user. - Place
error.tsxat the most specific level that makes sense, a widget-level failure shouldn't take down an entire dashboard if a narrower boundary would contain it (useunstable_catchErrorfor non-route-segment granularity). - Always provide a
global-error.tsxfor production apps, it's the only safety net for errors in the root layout itself, which no ordinaryerror.tsxcan reach. - Use
notFound()for genuinely missing resources (a post that doesn't exist) rather than manually rendering an inline "not found" message, it correctly triggers the segment's and the appropriate HTTP status.
Performance Tips
loading.tsxenables partial prefetching for dynamic routes (see Routing & Layouts), beyond the UX benefit, this is a direct navigation-speed lever, not just a cosmetic fallback.- A layout that reads runtime/uncached data does not fall back to a same-segment
loading.tsx, it blocks navigation until it finishes rendering instead. Wrapping the uncached access in its own<Suspense>, or moving the fetch intopage.tsx(whichloading.tsxdoes cover), avoids this navigation-blocking trap.
