Chapter 07 — loading.js, error.js & not-found.js
Chapter 07 — loading.js, error.js & not-found.js
Hey everyone! In plain React, you build loading spinners and error boundaries by hand, every time. Next.js recognizes three special filenames and wires up the loading/error/not-found UI automatically, per route. Let's see exactly how.
What we will cover:
loading.js— automatic Suspense boundary per routeerror.js— automatic error boundary per routenot-found.jsand thenotFound()function- How these three nest with your layouts
- A worked example of all three together
1. loading.js — Automatic Loading UI
app/
└── dashboard/
├── layout.js
├── page.js ← slow, fetches data
└── loading.js ← shown automatically while page.js is loading
// app/dashboard/loading.js
export default function Loading() {
return <p>Loading dashboard...</p>;
}
// app/dashboard/page.js
export default async function Dashboard() {
const data = await getSlowData(); // takes 2 seconds
return <div>{data.title}</div>;
}
You didn't write any <Suspense> yourself here. Under the hood, Next.js automatically wraps page.js in a <Suspense fallback={<Loading />}> for you. The layout above it (navbar, sidebar) renders instantly and stays visible — only the slow part shows the fallback.
2. error.js — Automatic Error Boundary
app/
└── dashboard/
├── page.js ← throws an error while fetching
└── error.js ← catches it automatically
// app/dashboard/error.js — MUST be a Client Component
"use client";
export default function Error({ error, reset }) {
return (
<div>
<h2>Something went wrong!</h2>
<p>{error.message}</p>
<button onClick={() => reset()}>Try again</button>
</div>
);
}
Two details that trip people up:
error.jsmust have"use client"— error boundaries are a React feature that only exists client-sidereset()doesn't reload the whole page — it tries re-rendering just the failed segment again
3. not-found.js & the notFound() Function
// app/blog/[slug]/not-found.js
export default function NotFound() {
return <h2>This post doesn't exist.</h2>;
}
// app/blog/[slug]/page.js
import { notFound } from "next/navigation";
export default async function BlogPost({ params }) {
const { slug } = await params;
const post = await getPost(slug);
if (!post) {
notFound(); // ← stops rendering, shows the nearest not-found.js
}
return <article>{post.content}</article>;
}
This is different from a thrown error — calling notFound() is an intentional "this specific thing doesn't exist" signal, and it correctly returns a 404 HTTP status, which matters for SEO.
4. How All Three Nest With Layouts
app/
├── layout.js ← root layout, always renders
├── error.js ← catches errors ANYWHERE below it
└── dashboard/
├── layout.js ← dashboard-specific chrome (sidebar etc.)
├── loading.js ← only covers dashboard's page.js
├── error.js ← more specific — catches dashboard errors first
└── page.js
Error boundary resolution: Next.js uses the CLOSEST error.js
to where the error happened, walking up if one doesn't exist there.
Same idea for loading.js — the closest one to the slow segment applies.
5. Worked Example — All Three Together
app/
└── products/
├── [id]/
│ ├── page.js ← fetches one product
│ ├── loading.js ← "Loading product..."
│ ├── error.js ← "Failed to load product" + retry button
│ └── not-found.js ← "Product not found" (id doesn't exist)
└── page.js ← product listing
// app/products/[id]/page.js
import { notFound } from "next/navigation";
export default async function ProductPage({ params }) {
const { id } = await params;
const product = await getProduct(id); // might throw → caught by error.js
if (!product) notFound(); // explicit "doesn't exist" → not-found.js
return <h1>{product.name}</h1>;
}
Three DIFFERENT outcomes, three DIFFERENT files, zero manual wiring: ======================================================================== Fetch takes 2 seconds → loading.js shows automatically Fetch throws (network down) → error.js shows automatically, with retry Product ID doesn't exist → not-found.js shows, correct 404 status
Key Points to Remember
loading.jsis an automatic Suspense fallback for itspage.js— no manual<Suspense>needederror.jsis an automatic error boundary — must be a Client Component, getserrorandreset()as propsnot-found.jspairs with callingnotFound()for "this specific thing doesn't exist" — and returns a real 404 status- All three resolve to the closest file up the folder tree — you can have route-specific ones or one shared at the root
- Layouts stay mounted and visible while nested
loading.js/error.jshandle just the broken/slow segment
What's Next?
Next chapter, we build things the other direction: Route Handlers (your API endpoints) and Server Actions (mutating data without writing a separate API at all).
Keep coding, keep learning! See you in the next one!
Post a Comment