Three files, no wiring
Next wraps your page in a <Suspense> with loading.tsx as the fallback, and in an error boundary with error.tsx as the fallback. You write the two components; the framework does the plumbing.
app/doors/
page.tsx
loading.tsx ← shown while page.tsx is being prepared
error.tsx ← catches a throw in this segment, below it
not-found.tsx ← for notFound(), and unmatched routes
loading.tsx
It appears instantly on navigation, so the interface never looks frozen. A skeleton shaped like the real content is worth more than a spinner: the page does not jump when the data lands.
export default function Loading() {
return (
<ul className="skeleton">
{Array.from({ length: 6 }, (_, i) => <li key={i} className="row" />)}
</ul>
);
}
error.tsx
reset()re-renders the segment — the right answer for a failed request.- It catches errors below it, not in the layout beside it. To catch a failing layout, the boundary has to be a level up.
app/global-error.tsxcatches a failure in the root layout, and must render its own<html>and<body>.- In production the message of a server error is replaced with a generic one and a digest — deliberately, so nothing internal leaks.
"use client";
export default function Error({ error, reset }) {
return (
<div role="alert">
<h2>That did not work</h2>
<p>{error.message}</p>
<button onClick={reset}>Try again</button>
</div>
);
}
not-found
import { notFound } from "next/navigation";
export default async function DoorPage({ params }) {
const { id } = await params;
const door = await getDoor(id);
if (!door) notFound(); // renders not-found.tsx, and sends a 404 status
return <h1>{door.name}</h1>;
}
Returning <p>Not found</p> sends a 200 with a page that says "not found". Search engines index it, monitoring counts it as a success, and the client cannot tell. notFound() sets the status.
Streaming: the part that changes how pages feel
Without Suspense, the slowest query on the page decides when anything is shown. With it, the shell is on screen in milliseconds and each section appears as it is ready — the same total time, a completely different experience.
import { Suspense } from "react";
export default function DoorsPage() {
return (
<main>
<h1>Doors</h1> {/* sent immediately */}
<Suspense fallback={<ListSkeleton />}>
<DoorList /> {/* streamed when its data lands */}
</Suspense>
<Suspense fallback={<p>Loading reviews…</p>}>
<Reviews /> {/* independently */}
</Suspense>
</main>
);
}
Where to put the boundary
- Around anything that fetches and is not needed for the first impression: recommendations, reviews, related items.
- Not around everything — a page that pops in seven times looks broken.
- Give each boundary a fallback the same shape and size as its content, or the layout shifts when it resolves.
What the user should see
| Situation | Show |
|---|---|
| Navigating | The new route’s skeleton, immediately |
| A slow section | A skeleton of that section only |
| Nothing found | 404 with a way onwards |
| Request failed | What failed, and a retry |
| Empty result | A normal empty state — not an error |