Module 27 — Loading, Error and Not Found UI
A production Next.js application should not show a blank screen while data is loading, crash when an unexpected error occurs, or display a generic page when a requested resource does not exist.
Next.js App Router provides special files for these situations:
1loading.tsx 2error.tsx 3not-found.tsx
Together, they create a predictable user experience:
1Request 2 ↓ 3Route 4 ↓ 5┌───────────────┐ 6│ │ 7Loading Result 8│ │ 9│ ┌──────┴──────┐ 10│ ↓ ↓ 11│ Success Failure 12│ ↓ ↓ 13│ Content Error UI 14│ 15└── Not Found → 404 UI
Why Loading, Error and Not Found UI Matter
Consider a course page:
1/courses/nextjs
The application may need to fetch the course from an API or database.
Without proper UI states:
1User 2 ↓ 3Click Course 4 ↓ 5Blank Screen 6 ↓ 7Wait 8 ↓ 9Data appears
With proper UI states:
1User 2 ↓ 3Click Course 4 ↓ 5Loading UI 6 ↓ 7Fetch Data 8 ↓ 9┌───────────────┐ 10│ │ 11Success Error 12│ │ 13Course Error UI
And if the course does not exist:
1Course 2 ↓ 3Not Found 4 ↓ 5404 UI
This makes the application feel much more reliable.
The Three Special Files
Next.js provides three important UI conventions:
1loading.tsx 2error.tsx 3not-found.tsx
They serve different purposes.
| File | Purpose |
|---|---|
loading.tsx | Shows UI while a route is loading |
error.tsx | Handles unexpected runtime errors |
not-found.tsx | Displays a 404-style UI for missing resources |
Think of them as:
1loading.tsx 2 ↓ 3"Please wait..." 4 5error.tsx 6 ↓ 7"Something went wrong." 8 9not-found.tsx 10 ↓ 11"We couldn't find that page."
loading.tsx
The loading.tsx file defines a loading UI for a route segment.
Example:
1app/ 2└── courses/ 3 └── [slug]/ 4 ├── page.tsx 5 └── loading.tsx
The loading component:
1export default function Loading() { 2 return ( 3 <main className="p-8"> 4 <div className="animate-pulse"> 5 Loading course... 6 </div> 7 </main> 8 ); 9}
When the route is waiting for server-rendered content, Next.js can show this loading UI.
Course Loading UI
A better production UI would use a skeleton.
1export default function Loading() { 2 return ( 3 <main className="mx-auto max-w-5xl p-6"> 4 <div className="animate-pulse space-y-6"> 5 <div className="h-10 w-2/3 rounded bg-gray-200" /> 6 7 <div className="h-5 w-full rounded bg-gray-200" /> 8 9 <div className="h-5 w-4/5 rounded bg-gray-200" /> 10 11 <div className="h-64 rounded-xl bg-gray-200" /> 12 </div> 13 </main> 14 ); 15}
The user sees the structure of the page before the real content arrives.
What Is a Skeleton UI?
A skeleton UI is a placeholder that resembles the final page.
For example:
1Before: 2 3┌──────────────────────────────┐ 4│ │ 5│ │ 6│ Loading... │ 7│ │ 8└──────────────────────────────┘
A skeleton:
1┌──────────────────────────────┐ 2│ ███████████████ │ 3│ ███████████████████████ │ 4│ ████████████████ │ 5│ │ 6│ ███████████████████████████ │ 7│ ███████████████████████ │ 8└──────────────────────────────┘
The second approach often feels faster because users can immediately understand the expected layout.
loading.tsx File Placement
Loading UI applies to its route segment.
For example:
1app/ 2├── loading.tsx 3│ 4└── courses/ 5 ├── loading.tsx 6 │ 7 └── [slug]/ 8 ├── page.tsx 9 └── loading.tsx
You can create loading states at different levels.
Conceptually:
1app/loading.tsx 2 ↓ 3Entire application area 4 5courses/loading.tsx 6 ↓ 7Courses area 8 9courses/[slug]/loading.tsx 10 ↓ 11Individual course
This gives you granular control over loading experiences.
error.tsx
The error.tsx file provides an error UI for unexpected errors in a route segment.
Example:
1app/ 2└── courses/ 3 └── [slug]/ 4 ├── page.tsx 5 ├── loading.tsx 6 └── error.tsx
A basic error component:
1"use client"; 2 3import { useEffect } from "react"; 4 5export default function Error({ 6 error, 7 reset, 8}: { 9 error: Error & { digest?: string }; 10 reset: () => void; 11}) { 12 useEffect(() => { 13 console.error(error); 14 }, [error]); 15 16 return ( 17 <main className="p-8 text-center"> 18 <h2 className="text-2xl font-bold"> 19 Something went wrong 20 </h2> 21 22 <p className="mt-2 text-gray-600"> 23 We couldn't load this course. 24 </p> 25 26 <button 27 onClick={() => reset()} 28 className="mt-6 rounded-lg bg-black px-4 py-2 text-white" 29 > 30 Try again 31 </button> 32 </main> 33 ); 34}
Notice:
1"use client";
An error.tsx boundary needs to be a Client Component because it uses client-side error boundary behavior and can provide interactive recovery such as reset().
Understanding reset()
The reset function lets the user attempt to render the failed route segment again.
1<button onClick={() => reset()}> 2 Try again 3</button>
The flow becomes:
1Request 2 ↓ 3Server Rendering 4 ↓ 5Error 6 ↓ 7error.tsx 8 ↓ 9User clicks "Try again" 10 ↓ 11reset() 12 ↓ 13Route attempts rendering again
This is especially useful for temporary failures.
Error Boundaries
An error boundary is a UI boundary that catches errors during rendering of its child portion of the application.
Conceptually:
1Error Boundary 2┌──────────────────────────────┐ 3│ │ 4│ Course Component │ 5│ │ 6└──────────────────────────────┘ 7 ↓ 8 Error 9 ↓ 10 Error Boundary 11 ↓ 12 Error UI
Without an appropriate boundary, an unexpected error can result in a poor user experience.
With a boundary:
1Application 2│ 3├── Navbar 4│ 5├── Course Area 6│ ↓ 7│ Error 8│ ↓ 9│ Error UI 10│ 11└── Footer
The goal is to isolate failures where appropriate rather than unnecessarily replacing the entire application UI.
not-found.tsx
not-found.tsx is used when a requested resource does not exist.
For example:
1/courses/does-not-exist
Your application can determine:
1Course exists? 2 │ 3 ├── Yes → Render course 4 │ 5 └── No → notFound()
Example:
1import { notFound } from "next/navigation"; 2 3export default async function CoursePage({ 4 params, 5}: { 6 params: Promise<{ slug: string }>; 7}) { 8 const { slug } = await params; 9 10 const course = await getCourse(slug); 11 12 if (!course) { 13 notFound(); 14 } 15 16 return ( 17 <main> 18 <h1>{course.title}</h1> 19 </main> 20 ); 21}
Creating a Custom 404 UI
Create:
1app/ 2└── courses/ 3 └── [slug]/ 4 ├── page.tsx 5 └── not-found.tsx
Then:
1export default function NotFound() { 2 return ( 3 <main className="mx-auto max-w-3xl p-12 text-center"> 4 <h1 className="text-4xl font-bold"> 5 Course Not Found 6 </h1> 7 8 <p className="mt-4 text-gray-600"> 9 The course you're looking for doesn't exist. 10 </p> 11 12 <a 13 href="/courses" 14 className="mt-6 inline-block rounded-lg bg-black px-5 py-3 text-white" 15 > 16 Browse Courses 17 </a> 18 </main> 19 ); 20}
Now:
1/courses/unknown-course 2 ↓ 3getCourse() 4 ↓ 5null 6 ↓ 7notFound() 8 ↓ 9Course not-found.tsx
notFound() vs error()
These two situations are different.
Resource does not exist
1if (!course) { 2 notFound(); 3}
Result:
1404 Not Found
Unexpected failure
1Database unavailable 2API crashed 3Unexpected exception
Result:
1error.tsx
Think:
1Expected absence 2 ↓ 3notFound() 4 5Unexpected failure 6 ↓ 7error.tsx
This distinction is extremely important.
API Errors
Consider:
1const response = await fetch( 2 "https://api.example.com/courses/nextjs" 3);
There are several possible outcomes.
1API Request 2 ↓ 3┌────┼──────────────┐ 4↓ ↓ ↓ 5200 404 500 6↓ ↓ ↓ 7Data Missing Server 8 ↓ ↓ 9Course Not Found / Error
Your application should handle these cases intentionally.
For example:
1async function getCourse(slug: string) { 2 const response = await fetch( 3 `https://api.example.com/courses/${slug}` 4 ); 5 6 if (response.status === 404) { 7 return null; 8 } 9 10 if (!response.ok) { 11 throw new Error("Failed to fetch course"); 12 } 13 14 return response.json(); 15}
Now:
1404 2 ↓ 3null 4 ↓ 5notFound() 6 7500 8 ↓ 9throw Error 10 ↓ 11error.tsx
This creates a clean separation.
Complete Course Page
A production-style course page might look like:
1import { notFound } from "next/navigation"; 2 3type Props = { 4 params: Promise<{ 5 slug: string; 6 }>; 7}; 8 9async function getCourse(slug: string) { 10 const response = await fetch( 11 `https://api.example.com/courses/${slug}` 12 ); 13 14 if (response.status === 404) { 15 return null; 16 } 17 18 if (!response.ok) { 19 throw new Error("Failed to fetch course"); 20 } 21 22 return response.json(); 23} 24 25export default async function CoursePage({ 26 params, 27}: Props) { 28 const { slug } = await params; 29 30 const course = await getCourse(slug); 31 32 if (!course) { 33 notFound(); 34 } 35 36 return ( 37 <main> 38 <h1>{course.title}</h1> 39 40 <p>{course.description}</p> 41 </main> 42 ); 43}
The important logic is:
1Fetch Course 2 ↓ 3┌────┴──────────┐ 4↓ ↓ 5404 Other error 6↓ ↓ 7notFound() throw Error 8↓ ↓ 9404 UI error.tsx
Loading + Error + Not Found Architecture
A course route can be structured as:
1app/ 2└── courses/ 3 └── [slug]/ 4 ├── page.tsx 5 ├── loading.tsx 6 ├── error.tsx 7 └── not-found.tsx
Each file has a specific responsibility:
1page.tsx 2 ↓ 3Normal content 4 5loading.tsx 6 ↓ 7Waiting for content 8 9error.tsx 10 ↓ 11Unexpected failure 12 13not-found.tsx 14 ↓ 15Resource doesn't exist
This is one of the most useful App Router patterns.
Suspense
React Suspense allows part of a UI to display a fallback while another part is waiting.
Conceptually:
1Page 2│ 3├── Header 4│ 5├── Course Content 6│ ↓ 7│ Suspense 8│ ↓ 9│ Loading Skeleton 10│ 11└── Footer
Example:
1import { Suspense } from "react"; 2 3export default function Page() { 4 return ( 5 <main> 6 <h1>Course</h1> 7 8 <Suspense fallback={<CourseSkeleton />}> 9 <CourseContent /> 10 </Suspense> 11 </main> 12 ); 13}
The fallback:
1function CourseSkeleton() { 2 return ( 3 <div className="animate-pulse"> 4 Loading course... 5 </div> 6 ); 7}
loading.tsx vs Suspense
They solve related but different architectural problems.
loading.tsx
Useful for a route segment:
1Route 2 ↓ 3loading.tsx 4 ↓ 5Page
Suspense
Useful for a particular part of the UI:
1Page 2├── Header 3├── Suspense → Course 4├── Sidebar 5└── Footer
Think:
1loading.tsx 2 ↓ 3Route-level loading 4 5Suspense 6 ↓ 7Component-level loading
Streaming UI
Next.js can progressively send UI to the browser instead of waiting for every piece of content to finish.
Imagine:
1Server 2 │ 3 ├── Header ──────────→ Browser 4 │ 5 ├── Course skeleton ─→ Browser 6 │ 7 ├── Course data ─────→ Browser 8 │ 9 └── Recommendations → Browser
The user can start seeing useful content sooner.
Conceptually:
1Request 2 ↓ 3Initial UI 4 ↓ 5Stream content 6 ↓ 7Complete page
This is one reason loading states and Suspense are important in modern Next.js applications.
Nested Loading States
Suppose:
1app/ 2└── courses/ 3 └── [slug]/ 4 ├── page.tsx 5 └── lessons/ 6 ├── page.tsx 7 └── loading.tsx
The lessons section can have its own loading experience.
Architecture:
1Course Page 2│ 3├── Course Header 4│ 5└── Lessons 6 ↓ 7 loading.tsx 8 ↓ 9 Lessons Content
This prevents the entire page from necessarily appearing as one giant loading state.
Nested Error Boundaries
The same concept applies to errors.
1app/ 2└── courses/ 3 ├── error.tsx 4 │ 5 └── [slug]/ 6 ├── error.tsx 7 └── lessons/ 8 └── error.tsx
Different boundaries can handle failures at different levels.
For example:
1Course 2│ 3├── Course Information 4│ 5└── Lessons 6 ↓ 7 Error 8 ↓ 9Lessons Error UI
This can preserve unaffected parts of the page.
Global Error Handling
You can also define higher-level error handling.
Conceptually:
1app/ 2├── error.tsx 3├── courses/ 4│ ├── error.tsx 5│ └── [slug]/ 6│ └── error.tsx
Use boundaries thoughtfully.
You want errors to be:
1Specific enough 2 + 3Recoverable where possible 4 + 5Not unnecessarily duplicated
Global Not Found
You can also create:
1app/not-found.tsx
for a broader fallback.
Example:
1export default function NotFound() { 2 return ( 3 <main className="mx-auto max-w-3xl p-12 text-center"> 4 <h1 className="text-5xl font-bold"> 5 404 6 </h1> 7 8 <p className="mt-4 text-gray-600"> 9 The page you requested could not be found. 10 </p> 11 12 <a 13 href="/" 14 className="mt-6 inline-block rounded-lg bg-black px-5 py-3 text-white" 15 > 16 Go Home 17 </a> 18 </main> 19 ); 20}
This provides a site-wide not-found experience.
Real-Life Course Architecture
For a course platform:
1app/ 2└── courses/ 3 └── [slug]/ 4 ├── page.tsx 5 ├── loading.tsx 6 ├── error.tsx 7 └── not-found.tsx
The request lifecycle:
1User 2 ↓ 3/courses/nextjs 4 ↓ 5Route Matching 6 ↓ 7loading.tsx 8 ↓ 9Fetch Course 10 ↓ 11┌────────────────────────┐ 12│ │ 13Course exists? │ 14│ │ 15├────────────┬───────────┤ 16↓ ↓ 17Yes No 18↓ ↓ 19Render notFound() 20Course ↓ 21↓ 404 UI 22Success
If the API fails:
1Fetch Course 2 ↓ 3API Error 4 ↓ 5throw Error 6 ↓ 7error.tsx 8 ↓ 9Try Again
Production Error Handling
Avoid exposing sensitive internal errors directly to users.
Bad:
1<p>{error.message}</p>
because an internal error might contain implementation details.
For example, an error could reveal:
1Database connection string 2Internal service name 3File paths 4SQL information
Prefer a safe message:
1<p> 2 We couldn't load this course. 3 Please try again. 4</p>
Log the technical error on the server or through your monitoring system, while showing users a safe message.
Error Logging
A useful pattern:
1"use client"; 2 3import { useEffect } from "react"; 4 5export default function Error({ 6 error, 7 reset, 8}: { 9 error: Error & { digest?: string }; 10 reset: () => void; 11}) { 12 useEffect(() => { 13 console.error(error); 14 }, [error]); 15 16 return ( 17 <main className="p-8"> 18 <h2>Something went wrong.</h2> 19 20 <button onClick={() => reset()}> 21 Try again 22 </button> 23 </main> 24 ); 25}
In a production application, you can send errors to an observability platform rather than relying only on console logging.
Loading States for API Requests
If data is fetched on the client, you may also need local loading state.
Example:
1"use client"; 2 3import { useEffect, useState } from "react"; 4 5export default function Courses() { 6 const [courses, setCourses] = useState([]); 7 const [loading, setLoading] = useState(true); 8 9 useEffect(() => { 10 async function loadCourses() { 11 try { 12 const response = await fetch("/api/courses"); 13 14 if (!response.ok) { 15 throw new Error("Failed to load courses"); 16 } 17 18 const data = await response.json(); 19 20 setCourses(data); 21 } finally { 22 setLoading(false); 23 } 24 } 25 26 loadCourses(); 27 }, []); 28 29 if (loading) { 30 return <div>Loading courses...</div>; 31 } 32 33 return ( 34 <div> 35 {courses.map((course: any) => ( 36 <div key={course.id}> 37 {course.title} 38 </div> 39 ))} 40 </div> 41 ); 42}
This is different from route-level loading.tsx.
1loading.tsx 2 ↓ 3Route-level loading 4 5useState/loading 6 ↓ 7Component-level client loading
Handling Client API Errors
For client-side fetching:
1"use client"; 2 3import { useEffect, useState } from "react"; 4 5export default function Courses() { 6 const [courses, setCourses] = useState([]); 7 const [error, setError] = useState<string | null>(null); 8 9 useEffect(() => { 10 async function loadCourses() { 11 try { 12 const response = await fetch("/api/courses"); 13 14 if (!response.ok) { 15 throw new Error("Unable to load courses"); 16 } 17 18 const data = await response.json(); 19 20 setCourses(data); 21 } catch { 22 setError("Unable to load courses."); 23 } 24 } 25 26 loadCourses(); 27 }, []); 28 29 if (error) { 30 return <p>{error}</p>; 31 } 32 33 return ( 34 <div> 35 {courses.map((course: any) => ( 36 <div key={course.id}> 37 {course.title} 38 </div> 39 ))} 40 </div> 41 ); 42}
The important idea is that client-side request failures should be represented explicitly in the UI.
Three Types of Failure
In a real application, distinguish:
1. Loading
1Data is not ready yet
Use:
1loading.tsx 2Suspense 3Skeleton 4Local loading state
2. Not Found
1Resource does not exist
Use:
1notFound() 2not-found.tsx
3. Unexpected Error
1Something failed unexpectedly
Use:
1error.tsx 2Error Boundary 3Safe error message 4Retry
The mental model:
1 Request 2 ↓ 3 Loading State 4 ↓ 5 Fetch Resource 6 ↓ 7 ┌─────────┼─────────┐ 8 ↓ ↓ ↓ 9 Success Not Found Error 10 ↓ ↓ ↓ 11 Page 404 UI Error UI
Recommended Course Page Structure
For a production learning platform:
1app/ 2└── courses/ 3 └── [slug]/ 4 ├── page.tsx 5 ├── loading.tsx 6 ├── error.tsx 7 └── not-found.tsx
Then:
1page.tsx 2 │ 3 ├── Fetch course 4 │ 5 ├── Course exists? 6 │ │ 7 │ ├── No → notFound() 8 │ │ 9 │ └── Yes → Render 10 │ 11 └── Unexpected error → error.tsx
And while waiting:
1page.tsx 2 ↑ 3loading.tsx
Common Mistakes
Mistake 1 — Showing a Blank Screen
Bad:
1Request 2 ↓ 3Nothing 4 ↓ 5Data appears
Better:
1Request 2 ↓ 3Skeleton 4 ↓ 5Data
Mistake 2 — Using Error UI for 404s
Do not treat:
1Course does not exist
as:
1Server crashed
Use:
1notFound();
for missing resources.
Mistake 3 — Using notFound() for Server Failures
This is also incorrect:
1Database unavailable 2 ↓ 3404
The course may exist.
A server/database failure should generally be handled as an error, not as a missing resource.
Mistake 4 — Using Robots for Error Handling
These are unrelated:
1robots.txt 2 ↓ 3Crawler behavior
versus:
1error.tsx 2 ↓ 3Application error UI
Mistake 5 — Exposing Internal Errors
Avoid showing:
1SQL connection failed: 2postgres://user:password@...
to users.
Show:
1Something went wrong. 2Please try again.
and log the technical details securely.
Module 27 Learning Checklist
After completing this module, you should understand:
loading.tsxerror.tsxnot-found.tsx- Route-level loading
- Skeleton UI
- Suspense
- Streaming UI
- Error boundaries
reset()notFound()- 404 handling
- API errors
- Server errors
- Client-side loading states
- Client-side API errors
- Nested loading states
- Nested error boundaries
- Global error handling
- Global not-found handling
- Safe error messages
- Error logging
- Production error architecture
Final Mental Model
The complete architecture is:
1 User Request 2 ↓ 3 Next.js 4 ↓ 5 Route Match 6 ↓ 7 loading.tsx 8 ↓ 9 Fetch Data 10 ↓ 11 ┌────────────┼────────────┐ 12 ↓ ↓ ↓ 13 Success Not Found Error 14 ↓ ↓ ↓ 15 page.tsx notFound() error.tsx 16 ↓ ↓ ↓ 17 Course UI 404 UI Error UI 18 │ │ 19 │ Try Again 20 │ ↓ 21 └──────────────┬──────────┘ 22 ↓ 23 User Experience
The key lesson is:
A production Next.js application should explicitly model loading, success, not-found, and error states. Use
loading.tsxand Suspense for waiting states,notFound()withnot-found.tsxfor missing resources, anderror.tsxfor unexpected failures. This creates resilient, predictable user experiences instead of blank screens, incorrect 404s, or unhandled crashes.