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 route
  • error.js — automatic error boundary per route
  • not-found.js and the notFound() 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.js must have "use client" — error boundaries are a React feature that only exists client-side
  • reset() 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.js is an automatic Suspense fallback for its page.js — no manual <Suspense> needed
  • error.js is an automatic error boundary — must be a Client Component, gets error and reset() as props
  • not-found.js pairs with calling notFound() 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.js handle 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!