Loading, Error, and Not Found UI in Next.js


A route is a promise made under more than ideal conditions. The product page may be waiting for a slow service, a dependency may throw unexpectedly, or a valid-looking URL may name a product that no longer exists. If every one of those situations becomes a spinner, a generic error, or an empty page, the application leaves people without an explanation or a next move.
The App Router makes these outcomes explicit with colocated file conventions. loading.tsx is a Suspense fallback, error.tsx is a segment error boundary, and not-found.tsx is the UI selected when code calls notFound(). They are related, but they communicate different facts about the request.
Three Outcomes, Three Intentions
Start by classifying the outcome before picking a component:
| Outcome | What it means | Boundary | Best next experience |
|---|---|---|---|
| Work is pending | A useful result may still arrive. | loading.tsx or local Suspense | Preserve context and show meaningful progress. |
| An unexpected failure occurred | The system could not fulfil this render. | error.tsx | Explain safely, log it, and offer retry or escape. |
| The requested resource is absent or unavailable | This is an expected product state. | notFound() + not-found.tsx | Say what is unavailable and route toward something useful. |
Next.js renders these conventions in a defined hierarchy around a route segment. The project-structure documentation (opens in new tab) places error, loading, and not-found boundaries between templates/layouts and the page or nested layout. Their scope is local: a boundary in app/catalog covers that segment and its descendants, which lets a dashboard stay usable while one panel fails.
Explore route outcomes
Select an outcome to reveal the file convention responsible for that part of the experience.
Route-boundary simulator
Choose a route outcome to see the Next.js file convention that owns the user experience. This is a browser simulation, not a real navigation.
app/products/loading.tsxA lightweight fallback appears while the segment streams. Shared layouts can stay interactive.
app/products/[slug]/
├── loading.tsx
├── error.tsx
├── not-found.tsx
└── page.tsxUse Loading UI to Make Progress Visible
Adding loading.tsx creates an instant, route-segment fallback. Next.js automatically nests it in a Suspense boundary around the page and children below it; the loading convention (opens in new tab) also means shared layouts can remain interactive while the new segment streams in.
// app/products/[slug]/loading.tsx
export default function LoadingProduct() {
return (
<article aria-busy="true" aria-label="Loading product">
<div className="h-8 w-2/3 animate-pulse rounded bg-slate-200" />
<div className="mt-6 h-72 animate-pulse rounded bg-slate-100" />
<div className="mt-4 h-4 w-full animate-pulse rounded bg-slate-100" />
</article>
);
}A skeleton should resemble the information that will arrive, not merely prove that an animation is available. Preserve the page’s spatial rhythm: a title-shaped block, a media-shaped block, and a few lines are more reassuring than a centre-screen spinner. Keep it lightweight—this is code sent precisely when the application is waiting.
Do not use route-level loading for every small, independent delay. A page with a slow recommendations panel should often render its primary product first and put a local <Suspense> boundary around recommendations. That gives the reader useful content sooner and limits the visual surface that waits.
import { Suspense } from "react";
export default function ProductPage() {
return (
<main>
<ProductDetail />
<Suspense fallback={<RecommendationsSkeleton />}>
<Recommendations />
</Suspense>
</main>
);
}Make Error UI Recoverable
error.tsx catches unexpected runtime errors in its segment and children. It must be a Client Component because React error boundaries need client-side behaviour. The Next.js reference (opens in new tab) supplies the thrown error and a reset function that tries to render the boundary again.
// app/products/[slug]/error.tsx
"use client";
import { useEffect } from "react";
export default function ProductError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
reportError({ digest: error.digest, route: "product" });
}, [error.digest]);
return (
<section role="alert">
<h2>We could not load this product</h2>
<p>Try again, or return to the catalog while we investigate.</p>
<button onClick={reset}>Try again</button>
</section>
);
}The user-facing message should explain impact and recovery, not expose stack traces, credentials, or implementation details. Keep the diagnostic context in your error reporting system. A digest or request correlation id can let support connect a person’s report with a server-side event without turning an error page into a debug console.
reset() is a retry, not a universal cure. It is appropriate for a transient fetch failure or a temporarily unhealthy upstream. It will repeat a deterministic programming bug just as faithfully. Give people an escape path—back to a parent list, a search page, or support—so retry is not the only decision.
Use Not Found for Expected Absence
Call notFound() when the route’s requested resource is not available in the product sense: a deleted document, a product that is not published, or a record outside the current tenant’s visible set. It throws a special control-flow signal that stops rendering this segment. The API reference (opens in new tab) notes that it also injects noindex metadata for search engines.
// app/products/[slug]/page.tsx
import { notFound } from "next/navigation";
export default async function ProductPage({ params }: PageProps) {
const { slug } = await params;
const product = await getPublicProduct(slug);
if (!product) notFound();
return <ProductDetail product={product} />;
}The corresponding not-found.tsx is a chance to retain the person, not a dead end:
import Link from "next/link";
export default function ProductNotFound() {
return (
<section>
<h1>This product is not available</h1>
<p>It may have moved, sold out, or been removed from the catalogue.</p>
<Link href="/products">Browse all products</Link>
</section>
);
}Do not turn authorization failures into a leaky catalogue of private records. A scoped data function such as getPublicProduct can return no result both for missing and inaccessible resources when that is the appropriate information policy. The UI then communicates absence without confirming private details.
Design Boundaries as a Route System
Boundary placement is architecture. Put a loading boundary where a whole route segment genuinely waits. Put an error boundary where a team can own recovery and diagnostics. Put a not-found boundary close enough to explain the missing resource in domain language. A root-level fallback is still necessary, but it should not be the only resilience design in the app.
Streaming changes one operational detail worth understanding. Once a response begins streaming, headers may already be committed. The Next.js loading documentation explains that a streamed not-found UI can therefore have a 200 HTTP status while receiving noindex markup; if a strict 404 status matters for a particular integration, establish absence before a Suspense boundary begins streaming. Measure and test the behaviour your deployment actually serves rather than assuming an HTTP code tells the whole story.
Test each boundary as a contract:
- Throttle or delay a representative fetch and confirm the loading UI is meaningful and keyboard context remains usable.
- Force a recoverable failure, verify reporting happens once, then verify retry and escape links work.
- Request a missing and an inaccessible identifier; confirm the correct not-found policy and robots metadata.
- Check direct navigation and client navigation, because streaming and prefetched loading behaviour can differ.
Key Takeaways
loading.tsx,error.tsx, andnot-found.tsxrepresent pending work, unexpected failure, and expected absence—not interchangeable generic fallbacks.- Use informative, lightweight loading UI at route boundaries and local Suspense for independent slow regions.
- Error boundaries are Client Components; log safe diagnostics, offer a retry when appropriate, and always give people another route forward.
- Use
notFound()for expected resource absence and make authorization decisions in scoped data access, not in the error copy. - Place and test boundaries as part of route architecture, including streaming-aware status and SEO behaviour.
Next, we will compose data fetching across these boundaries so a page can stream useful work, avoid waterfalls, and keep each component responsible for the data it actually needs.