EthicsComputerCourses
Join training +91 90532 10052

Loading, Error & Not Found UI

Next.js · Lesson 8 of 27 Intermediate 7 min read Updated

Real apps wait for slow networks, hit bugs and receive links to pages that do not exist. Instead of a blank screen or a frightening crash, Next.js lets you add three special files to any route folder: loading, error and not-found. Each one gives users a friendly screen for one of these situations.

In this lesson you will add a loading skeleton, write an error boundary with a "Try again" button, return proper 404 pages, and handle errors that happen in the root layout.

Where these files fit

Inside one route folder, Next.js nests the special files in a fixed order. Reading from the outside in:

Text
<Layout>
  <Template>
    <ErrorBoundary fallback={<Error />}>        from error.jsx
      <Suspense fallback={<Loading />}>         from loading.jsx
        <NotFoundBoundary fallback={<NotFound />}>  from not-found.jsx
          <Page />

Two things follow from this order. A loading file covers the page while it loads, and an error file catches crashes in the page and in everything below it, but not in the layout of the same folder, because the layout sits outside the boundary.

loading.jsx: instant loading states

Put a loading.jsx next to a page.jsx and Next.js wraps the page in a React <Suspense> boundary with your component as the fallback. As soon as the user clicks a link, they see the loading UI while the server prepares the page, and the shared layouts stay usable.

React JSX
// app/timetable/loading.jsx
export default function Loading() {
  return (
    <div aria-busy="true">
      <h1>Train timetable</h1>
      <p>Loading today's trains...</p>
    </div>
  );
}

A good loading state looks like the page it replaces: the same heading, grey boxes where the cards will be. This is called a skeleton. The example below shows the same idea in plain React. A promise that takes 1.5 seconds stands in for a slow database, and <Suspense> shows the fallback until use() gets the data:

React JSXTry it yourself
import { Suspense, use } from "react";
import { createRoot } from "react-dom/client";

function fetchTrains() {
  return new Promise((resolve) =>
    setTimeout(() => resolve(["12015 Ajmer Shatabdi 06:10", "12425 Jammu Rajdhani 20:40"]), 1500)
  );
}
const trainsPromise = fetchTrains();

function TimetablePage() {
  const trains = use(trainsPromise);
  return (
    <ul>
      {trains.map((t) => <li key={t}>{t}</li>)}
    </ul>
  );
}

function Loading() {
  return <p style={{ color: "#6b7280" }}>Loading today's trains...</p>;
}

createRoot(document.getElementById("root")).render(
  <>
    <h1>Train timetable</h1>
    <Suspense fallback={<Loading />}>
      <TimetablePage />
    </Suspense>
  </>
);

For 1.5 seconds you see "Loading today's trains...", then the two trains replace it. The heading outside <Suspense> appears immediately, just like a layout in Next.js. You will learn finer-grained loading with your own <Suspense> boundaries in Streaming & Suspense.

error.jsx: recovering from crashes

An error boundary is a React component that catches errors thrown while rendering its children and shows a fallback instead of crashing the whole app. In Next.js you create one by adding error.jsx to a folder. It must be a Client Component, because it needs state and click handlers:

React JSX
// app/timetable/error.jsx
"use client";

import { useEffect } from "react";

export default function TimetableError({ error, retry }) {
  useEffect(() => {
    console.error(error); // send to your error-reporting service
  }, [error]);

  return (
    <div role="alert">
      <h2>Sorry, the timetable could not be loaded.</h2>
      <button onClick={() => retry()}>Try again</button>
    </div>
  );
}

The component receives two props:

  • error is the value that was thrown. Errors from Server Components arrive as an Error, but in production their message is replaced by a generic one so secrets such as database details never reach the browser. Use error.digest, a short ID, to find the full error in your server logs.
  • retry() fetches the segment again from the server and re-renders it. If the problem was temporary, such as a network hiccup, the page appears.

The runnable example uses a classic React class error boundary, which is what error.jsx builds for you. Click "Break the page", then "Try again":

React JSXTry it yourself
import { Component, useState } from "react";
import { createRoot } from "react-dom/client";

let shouldFail = false;

function Timetable() {
  if (shouldFail) throw new Error("Railway API timed out");
  return <p>12015 Ajmer Shatabdi departs at 06:10.</p>;
}

class ErrorBoundary extends Component {
  state = { error: null };
  static getDerivedStateFromError(error) {
    return { error };
  }
  render() {
    if (this.state.error) {
      return (
        <div role="alert" style={{ background: "#fee2e2", padding: 8 }}>
          <p>Sorry, the timetable could not be loaded. ({this.state.error.message})</p>
          <button onClick={() => { shouldFail = false; this.setState({ error: null }); }}>Try again</button>
        </div>
      );
    }
    return this.props.children;
  }
}

function App() {
  const [, force] = useState(0);
  return (
    <>
      <button onClick={() => { shouldFail = true; force((n) => n + 1); }}>Break the page</button>
      <ErrorBoundary>
        <Timetable />
      </ErrorBoundary>
    </>
  );
}

createRoot(document.getElementById("root")).render(<App />);

After "Break the page", the red box shows "(Railway API timed out)" while the button above it still works. That is the point of a boundary: only the broken part is replaced. "Try again" renders the timetable again.

Error boundaries catch errors during rendering. They do not catch errors inside event handlers such as onClick; handle those with try/catch and state. For an error boundary around any component (not only a whole route segment), Next.js 16.3 added the catchError function from next/error.

Not found pages

When data does not exist, call notFound() from next/navigation. It stops rendering and shows the nearest not-found.jsx, and the response gets the HTTP status 404, which tells search engines not to index the page.

React JSX
// app/courses/[slug]/page.jsx
import { notFound } from "next/navigation";
import { getCourse } from "@/lib/courses";

export default async function CoursePage({ params }) {
  const { slug } = await params;
  const course = await getCourse(slug);
  if (!course) notFound();
  return <h1>{course.title}</h1>;
}
React JSX
// app/courses/[slug]/not-found.jsx
import Link from "next/link";

export default function CourseNotFound() {
  return (
    <>
      <h1>Course not found</h1>
      <p>It may have been renamed. <Link href="/courses">See all courses</Link>.</p>
    </>
  );
}

app/not-found.jsx at the root has a second job: it is also shown for every URL that matches no route at all, such as a mistyped link.

Errors in the root layout: global-error

Because error.jsx does not catch errors in the layout of its own folder, an error in the root layout needs something else: app/global-error.jsx. It replaces the root layout when active, so it must render its own <html> and <body>. It is also a Client Component and receives the same error and retry props. It does not include your global CSS, so keep it simple.

Common mistakes

  • Forgetting "use client" in error.jsx. Error boundaries are Client Components; without the directive you get a build error.
  • Expecting error.jsx to catch layout errors in the same folder. Put the error file one level higher, or use global-error.jsx for the root layout.
  • Showing error.message to users in production. Server errors arrive with a generic message by design. Log the digest and show friendly text.
  • Returning a "not found" message with status 200. Call notFound() so the response is a real 404.

Key points

  • loading.jsx wraps the page in <Suspense> and shows instantly while the page loads.
  • error.jsx is a Client Component error boundary with error and retry props; it does not cover its own folder's layout.
  • retry() re-fetches and re-renders the segment; older code uses reset().
  • notFound() plus not-found.jsx gives a proper 404; the root not-found.jsx also handles unknown URLs.
  • global-error.jsx handles root layout errors and must render <html> and <body>.

Exercise

For the route app/results/[roll]/page.jsx that shows exam results by roll number, write: a loading.jsx with a skeleton heading, an error.jsx that says "Results server is busy" with a Try again button, and change the page so an unknown roll number shows not-found.jsx with the text "No result for this roll number".

Show solution
React JSX
// app/results/[roll]/loading.jsx
export default function Loading() {
  return <h1 aria-busy="true">Fetching result...</h1>;
}

// app/results/[roll]/error.jsx
"use client";
export default function ResultsError({ retry }) {
  return (
    <div role="alert">
      <p>Results server is busy. Please try again in a minute.</p>
      <button onClick={() => retry()}>Try again</button>
    </div>
  );
}

// app/results/[roll]/not-found.jsx
export default function NoResult() {
  return <p>No result for this roll number.</p>;
}

// app/results/[roll]/page.jsx
import { notFound } from "next/navigation";
import { getResult } from "@/lib/results";

export default async function ResultPage({ params }) {
  const { roll } = await params;
  const result = await getResult(roll);
  if (!result) notFound();
  return <h1>{result.name}: {result.percentage}%</h1>;
}

Each special file sits in the same folder as the page, so it applies only to this route and its children.

Quick Quiz

1. What does loading.jsx do behind the scenes?

2. Why must error.jsx start with "use client"?

3. Which function shows the nearest not-found.jsx and returns a 404 status?

4. An error is thrown inside app/dashboard/layout.jsx. Which file can catch it?

Learn Next.js with a mentor

Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call