02 · Loading, Error & Not-Found UI¶
Every data-driven page has three states besides "success": still loading, failed, and "this thing doesn't exist". In a client-only React app you handle them with state variables in each component. In the App Router you handle them with three special files per route segment — and the framework wires them into React's Suspense and error boundaries for you.
loading.tsx: instant feedback¶
export default function Loading() {
return (
<div aria-busy="true" aria-live="polite">
<p>Loading dashboard…</p>
<div className="skeleton" style={{ height: 120 }} />
</div>
);
}
While app/dashboard/page.tsx is awaiting its data, this component is shown in its
place. The layouts above it are already on screen and interactive. Two effects:
- Navigation feels instant. When you click a
<Link>to/dashboard, the loading UI is part of what was prefetched, so it appears immediately. - The server can stream. The HTML up to the loading state is sent at once; the page's content follows in the same response when ready.
Prefer skeletons shaped like the real content over a spinner — they reduce layout shift
when the content arrives. For finer-grained control (several independently loading
sections in one page), use <Suspense> directly; Level 3 · 02 covers that.
error.tsx: contain failures¶
"use client"; // error boundaries must be Client Components
import { useEffect } from "react";
export default function DashboardError({
error,
retry,
}: {
error: Error & { digest?: string };
retry: () => void;
}) {
useEffect(() => {
// report to your error tracker here
console.error(error);
}, [error]);
return (
<div role="alert">
<h2>We couldn’t load your dashboard.</h2>
<p>Reference: {error.digest ?? "n/a"}</p>
<button onClick={() => retry()}>Try again</button>
</div>
);
}
If anything in the segment throws while rendering — a failed fetch, a bug — this component replaces the page, while the layout and the rest of the app keep working.
retry()re-fetches and re-renders the segment. It became a stable prop in Next.js 16.3 (added asunstable_retryin 16.2). On earlier versions usereset(), which re-renders without re-fetching server data — so for a server-side failure you'd combine it withrouter.refresh().- In production, error messages from Server Components are replaced with a generic
message so internals don't leak to users;
error.digestis a hash you can match against server logs, where the full error is printed.
Where the boundary sits¶
error.tsx wraps the segment's page.tsx, nested layouts, loading.tsx and
not-found.tsx — but not the layout.tsx in the same folder. An error in
app/dashboard/layout.tsx is caught by app/error.tsx, one level up. For the root
layout itself, add app/global-error.tsx, which must render its own <html> and
<body> because it replaces the root layout when active:
"use client";
export default function GlobalError({ retry }: { error: Error & { digest?: string }; retry: () => void }) {
return (
<html lang="en">
<body>
<h1>Something went wrong</h1>
<button onClick={() => retry()}>Reload</button>
</body>
</html>
);
}
not-found.tsx and notFound()¶
import Link from "next/link";
export default function ProductNotFound() {
return (
<main>
<h1>Product not found</h1>
<p>It may have been discontinued. <Link href="/products">Browse all products</Link>.</p>
</main>
);
}
Call notFound() from next/navigation in a page, layout, generateMetadata or
Server Action, and the nearest not-found.tsx renders. Like redirect(), it throws
internally, so code after it doesn't run (and TypeScript knows the value can't be
null afterwards).
app/not-found.tsx at the root also handles URLs that match no route at all.
The status-code subtlety: streaming vs 404¶
Here's something you only discover by testing. We built a test app with
app/blog/[slug]/page.tsx (calling notFound() for unknown slugs, with the default
dynamicParams = true) and an app/blog/loading.tsx. Then:
curl -s -o /dev/null -w "%{http_code}\n" localhost:3111/blog/nope
curl -s -o /dev/null -w "%{http_code}\n" localhost:3111/nothing-here
Observed on Next.js 16.3.6:
The unknown blog slug returned 200, while a URL matching no route returned 404.
The body of the 200 response contained the not-found UI and a
<meta name="robots" content="noindex"> tag.
Why? The loading boundary let Next.js start streaming the response — status line and
headers included — before the page component ran. By the time notFound() was
called, the 200 had already been sent. Next.js compensates by injecting noindex so
search engines don't index the page, but API clients and uptime monitors that only look
at status codes will be misled.
When the correct status matters, resolve "does this exist?" before anything can
stream: don't wrap that route in a loading.tsx above the check, use
dynamicParams = false with a known list (we saw a clean 404 with that setup in the
Level 1 blog project), or check existence in Proxy for routes that must 404 strictly.
Worked example: a resilient product page¶
app/products/
├── page.tsx
├── error.tsx ← catches failures in the list and in [id]
└── [id]/
├── page.tsx
├── loading.tsx ← skeleton while one product loads
└── not-found.tsx ← friendly 404 for unknown ids
import { notFound } from "next/navigation";
import { getProduct } from "@/lib/products";
export default async function ProductPage({ params }: PageProps<"/products/[id]">) {
const { id } = await params;
const product = await getProduct(Number(id));
if (!product) notFound();
if (product.discontinued) throw new Error(`Product ${id} is discontinued`); // demo: goes to error.tsx
return <h1>{product.name}</h1>;
}
Walk through the states: a slow getProduct shows the skeleton; an unknown ID shows
the product-specific not-found page; a thrown error shows app/products/error.tsx with
a retry button, and the site header in the root layout stays usable throughout.
How It Actually Works¶
For each segment, Next.js composes the special files into a fixed element hierarchy:
<Layout>
<Template>
<ErrorBoundary fallback={<Error />}>
<Suspense fallback={<Loading />}>
<NotFoundBoundary fallback={<NotFound />}>
<Page />
</NotFoundBoundary>
</Suspense>
</ErrorBoundary>
</Template>
</Layout>
That ordering explains every rule above: the error boundary is inside the layout (so
it can't catch the layout's own errors), the loading state is inside the error boundary
(so a failure while loading is still caught), and notFound() throws a special error
that the not-found boundary recognises. On the server, React's streaming renderer sends
the shell with <Loading /> in place of anything still suspended, then later sends the
finished content with a small inline script that swaps it into position. Once the first
bytes are flushed, the HTTP status can no longer change — the root of the 200-vs-404
behaviour.
Common mistakes¶
- Forgetting
"use client"inerror.tsx. Error boundaries are a client concept; the build will complain. - Expecting
error.tsxto catch errors in the same folder's layout. Put a boundary one level up or useglobal-error.tsx. - Showing
error.messageto users in development and assuming the same in production. Production hides Server Component messages. Log bydigest. - Catching
notFound()orredirect()in atry/catch. They work by throwing. - Relying on the status code of streamed 404s. Test it with
curl. - A single spinner for the whole app in
app/loading.tsx. Place loading UI as close as possible to the slow data.
Exercise¶
- Add
loading.tsx,error.tsxandnot-found.tsxto a dynamic route in your project. Useawait new Promise(r => setTimeout(r, 2000))in the page to see the loading UI, and a query flag (?fail=1) to throw. - Build and start the app, then use
curl -w "%{http_code}"to record the status for an unknown ID with and without theloading.tsxfile. Explain what you see. - Add
app/global-error.tsxand deliberately throw in the root layout (temporarily). Confirm your global error UI appears in a production build.