Last updated: 2026-10-08

Loading and Error States

An application that fetches data will always have moments when data is not yet available and moments when something goes wrong. How those moments are handled — what the user sees, how gracefully the application recovers — is as much a part of the product experience as anything else. A blank screen while data loads, or a crashed page with no recovery path, signals an unfinished application regardless of how polished everything else looks.

Next.js treats loading and error states as first-class concerns in the routing architecture, not as afterthoughts to be handled individually in every component. The file conventions loading.tsx and error.tsx give these states a structural home — placed once at the right level, they apply automatically to every route within their scope.


Loading States

The loading.tsx File

A loading.tsx file placed inside a route directory creates a loading state for that route. When the page within that directory is fetching data on the server, the loading component is shown to the user in its place — instantly, before any data has arrived.

app/
└── dashboard/
    ├── loading.tsx    ← shown while dashboard/page.tsx fetches data
    └── page.tsx
// app/dashboard/loading.tsx
export default function DashboardLoading() {
  return (
    <div>
      <div className="skeleton-header" />
      <div className="skeleton-content" />
    </div>
  );
}

Next.js automatically wraps the page in a React <Suspense> boundary using this component as the fallback. The user sees the loading component immediately — the shell of the page arrives before the data does. When the server finishes fetching and rendering the page, the content streams in and replaces the loading component. No client-side state management, no conditional rendering, no if (isLoading) return <Spinner /> inside the page component. The file convention handles it.

This is streaming — the response begins flowing to the browser before it is complete, with loading placeholders filling the positions of sections that are still being prepared. The user never stares at a blank screen.


Granular Loading with <Suspense>

The loading.tsx file applies to the entire page. For pages with multiple sections that fetch data independently, a more granular approach is often better: wrapping individual slow sections in <Suspense> boundaries, so that the parts of the page that are ready appear immediately while specific sections show their own loading states.

// app/dashboard/page.tsx
import { Suspense } from 'react';
import { RevenueChart } from '@/components/RevenueChart';
import { RecentOrders } from '@/components/RecentOrders';
import { TopProducts } from '@/components/TopProducts';

export default function DashboardPage() {
  return (
    <div>
      <h1>Dashboard</h1>

      <Suspense fallback={<ChartSkeleton />}>
        <RevenueChart />
      </Suspense>

      <Suspense fallback={<TableSkeleton />}>
        <RecentOrders />
      </Suspense>

      <Suspense fallback={<ListSkeleton />}>
        <TopProducts />
      </Suspense>
    </div>
  );
}

Each of RevenueChart, RecentOrders, and TopProducts fetches its own data independently. Whichever completes first streams its content to the browser first. The heading Dashboard appears immediately. The three sections fill in progressively as their data becomes available — in whatever order the server produces them.

This pattern — composing pages from independently streaming sections — is one of the most powerful capabilities of the App Router and one of the clearest demonstrations of why the streaming model is a genuine improvement over waiting for all data before rendering anything.

Skeleton Screens

A skeleton screen is a loading placeholder that mirrors the shape and layout of the content it is waiting for, without showing any actual data. It gives the user a sense of the structure about to appear and reduces the perceived wait time compared to a generic spinner.

// components/TableSkeleton.tsx
export function TableSkeleton() {
  return (
    <div>
      {Array.from({ length: 5 }).map((_, i) => (
        <div key={i} className="skeleton-row" />
      ))}
    </div>
  );
}

Skeleton screens passed as the fallback prop to <Suspense> boundaries are the idiomatic approach to loading states in the App Router.


Error States

The error.tsx File

An error.tsx file placed inside a route directory creates an error boundary for that route. If the page component — or any component rendered beneath it — throws an error, the error component is rendered in its place rather than crashing the entire application.

app/
└── dashboard/
    ├── error.tsx    ← catches errors thrown within dashboard routes
    ├── loading.tsx
    └── page.tsx
// app/dashboard/error.tsx
'use client'; // Error components must be Client Components

export default function DashboardError({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  return (
    <div>
      <h2>Something went wrong in the dashboard.</h2>
      <p>{error.message}</p>
      <button onClick={reset}>Try again</button>
    </div>
  );
}

Error components must be Client Components — the 'use client' directive is required. This is because error recovery involves browser-side behaviour: the reset function re-renders the failed route segment by attempting to render the page component again, and triggering a re-render requires the React runtime in the browser.

The error prop carries the error that was thrown. The reset prop is a function that, when called, attempts to re-render the failed segment — a retry mechanism that allows users to recover from transient errors, such as a network request that timed out, without doing a full page reload.

How Error Boundaries Work

An error.tsx file wraps the page.tsx at its level in a React error boundary. If the page throws, the error boundary catches it and renders the error component instead. The layouts above it remain intact — the navigation bar, the sidebar, the shell of the application are all unaffected. Only the specific section that failed is replaced with the error component.

This containment is deliberate and important. In an application without error boundaries, a single error in one page can crash the entire UI, leaving the user with a blank or broken screen and no recovery path. With error boundaries at the route level, errors are local. A broken dashboard does not affect the settings page. A failed product listing does not take down the navigation.

The Error Hierarchy

Error boundaries nest, following the same hierarchy as layouts. An error.tsx at app/dashboard/error.tsx catches errors from routes under /dashboard. An error boundary at app/error.tsx catches errors from the entire application. A route segment with no error.tsx propagates its errors up to the nearest ancestor error boundary.

app/
├── error.tsx               ← catches errors from the entire application
├── page.tsx
└── dashboard/
    ├── error.tsx           ← catches errors specific to /dashboard
    ├── page.tsx
    └── settings/
        └── page.tsx        ← errors here propagate up to dashboard/error.tsx

Placing error boundaries at the appropriate level — close enough to the failure point to provide a contextually useful message, but not so granular that every component needs its own — is a judgment call that depends on the application's structure and the value of context-specific error messages.

Handling Errors in Layouts

The error.tsx convention does not catch errors thrown by the layout at the same level — it only catches errors from the page and components below it. If an error is thrown in app/dashboard/layout.tsx, the app/dashboard/error.tsx does not catch it, because the error boundary is rendered inside the layout, not around it.

To catch errors from a layout, the error boundary must be defined in the parent — app/error.tsx would catch an error from app/dashboard/layout.tsx.


The global-error.tsx File

A global-error.tsx file at the root of the app/ directory is the last line of defence — it catches errors that occur in the root layout itself, which app/error.tsx cannot catch.

// app/global-error.tsx
'use client';

export default function GlobalError({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  return (
    <html>
      <body>
        <h1>Something went wrong.</h1>
        <button onClick={reset}>Try again</button>
      </body>
    </html>
  );
}

Because global-error.tsx replaces the root layout when it activates, it must include its own <html> and <body> tags — the root layout that normally provides them is the thing that failed. This is an edge case rather than a common occurrence, but the convention exists for completeness.


Not Found States

A specific and common kind of error is a resource that does not exist — a blog post with a slug that has no matching record, a user profile for an ID that is not in the database. This is not a server error; it is a legitimate application state, and it deserves specific handling rather than a generic error message.

The not-found.tsx file, covered in the Pages and Layouts topic, handles this case. The notFound() function from next/navigation triggers it explicitly from within a page component.

import { notFound } from 'next/navigation';

export default async function BlogPost({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const post = await db.post.findUnique({ where: { slug } });

  if (!post) {
    notFound(); // renders not-found.tsx for this route segment
  }

  return <article>{post.content}</article>;
}

Distinguishing between "this resource does not exist" (notFound()) and "something went wrong trying to retrieve it" (a thrown error caught by error.tsx) is good practice — it allows users to understand whether they have landed on a genuinely absent page or encountered a failure in the system.


Error Handling in Route Handlers

Route Handlers — the API endpoints defined in route.ts files — do not use the error.tsx file convention. They are HTTP endpoints, not UI components, and their errors are expressed as HTTP responses.

The correct approach is to wrap Route Handler logic in a try/catch and return an appropriate HTTP status code and error body, exactly as shown in the Route Handlers topic.

export async function GET(
  _request: Request,
  { params }: { params: Promise<{ id: string }> }
) {
  try {
    const { id } = await params;
    const post = await db.post.findUnique({ where: { id: Number(id) } });

    if (!post) {
      return Response.json({ error: 'Post not found' }, { status: 404 });
    }

    return Response.json(post);
  } catch (error) {
    return Response.json({ error: 'Internal server error' }, { status: 500 });
  }
}

A Complete Picture

The loading and error conventions compose into a layered system that handles the full range of states a route can be in:

StateFileBehaviour
Data is being fetchedloading.tsxStreaming placeholder shown until content arrives
An error was thrownerror.tsxError boundary — contains the failure, provides a recovery path
A resource does not existnot-found.tsxSpecific not-found UI triggered by notFound()
The root layout failedglobal-error.tsxLast-resort error boundary with its own <html> and <body>

Placing these files thoughtfully — at the level in the hierarchy where they are most useful — is how a Next.js application gracefully handles everything that can go wrong between data being requested and content appearing on screen.