Loading, Error & Not Found UI
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:
<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.
// 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:
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:
// 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:
erroris the value that was thrown. Errors from Server Components arrive as anError, but in production their message is replaced by a generic one so secrets such as database details never reach the browser. Useerror.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":
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.
// 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>;
}// 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"inerror.jsx. Error boundaries are Client Components; without the directive you get a build error. - Expecting
error.jsxto catch layout errors in the same folder. Put the error file one level higher, or useglobal-error.jsxfor the root layout. - Showing
error.messageto users in production. Server errors arrive with a generic message by design. Log thedigestand show friendly text. - Returning a "not found" message with status 200. Call
notFound()so the response is a real 404.
Key points
loading.jsxwraps the page in<Suspense>and shows instantly while the page loads.error.jsxis a Client Component error boundary witherrorandretryprops; it does not cover its own folder's layout.retry()re-fetches and re-renders the segment; older code usesreset().notFound()plusnot-found.jsxgives a proper 404; the rootnot-found.jsxalso handles unknown URLs.global-error.jsxhandles 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
// 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?
Next.js wraps page in <Suspense> and uses your loading component as the fallback.
2. Why must error.jsx start with "use client"?
React error boundaries hold state and handle clicks such as "Try again", which requires a Client Component.
3. Which function shows the nearest not-found.jsx and returns a 404 status?
notFound() from next/navigation renders the not-found UI with a proper 404 response.
4. An error is thrown inside app/dashboard/layout.jsx. Which file can catch it?
An error boundary does not wrap the layout of its own folder, so a parent boundary must 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
