Loading and error states

Give every route a waiting state and a failure state, and let the fast parts arrive first.

05of 16
≈ 10Min read
7Parts

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.

put them beside a page and they apply to it
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.

a skeleton, not a spinner
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.tsx catches 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.
it must be a client component — it holds state
"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

a 404 that is a real 404
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>;
}
Not a rendered message

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.

the shell goes out first; slow pieces follow
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

SituationShow
NavigatingThe new route’s skeleton, immediately
A slow sectionA skeleton of that section only
Nothing found404 with a way onwards
Request failedWhat failed, and a retry
Empty resultA normal empty state — not an error

Practice