Loading & Error States
Your App's Safety Net for Imperfect Networks
LinkedIn Hook
"Our users were staring at a blank white screen for 3 seconds every time they navigated to the dashboard."
No spinner. No skeleton. No feedback at all. Just... nothing. And when an API failed? An ugly gray error page crashed the entire app.
The fix wasn't complex engineering. It was three files:
loading.js,error.js, andnot-found.js. Three files that Next.js already expected us to create — we just never did.Most developers build the happy path first and treat loading and error states as an afterthought. But your users spend more time in loading states than you think, and they will hit errors eventually. The question is whether your app handles it gracefully or falls apart.
In Lesson 4.3, you'll learn how Next.js turns loading and error handling from a chore into a convention — with automatic Suspense boundaries, per-route error isolation, and streaming that makes your app feel instant.
Read the full lesson -> [link]
#NextJS #React #LoadingStates #ErrorHandling #Suspense #WebPerformance #InterviewPrep #FrontendDevelopment
What You'll Learn
- How
loading.jscreates automatic Suspense boundaries for every route segment - How
error.jscreates automatic error boundaries that isolate failures per route - How
not-found.jshandles missing content at both the app and route level - What streaming with Suspense is and why it makes your app feel faster
- How to build skeleton UIs that match your actual content layout
- How nested loading states work and why they prevent full-page loading spinners
The Hospital Analogy — Triage, Not Collapse
Imagine a hospital emergency room. When a patient arrives, the hospital does not freeze all operations until the patient is diagnosed. Instead:
The waiting room (loading.js) gives the patient immediate feedback: "You're checked in. A doctor is coming." The patient knows something is happening. They see a waiting room, not a locked door. This is your loading state — instant visual feedback that the content is on its way.
The isolation ward (error.js) contains problems. If one patient has a contagious illness, the hospital doesn't shut down every ward. It isolates the problem to one room. Your error boundary does the same thing — if the billing API fails, the patient dashboard still works. The error is contained, not contagious.
The "Patient Not Found" desk (not-found.js) handles cases where someone asks for a patient who doesn't exist. Instead of crashing the hospital's system, the receptionist says: "We don't have a record for that patient. Here are some things you can do." A clear, helpful dead end.
Streaming is like a doctor giving updates as they work. Instead of waiting 20 minutes for a full diagnosis, the doctor says: "Vitals are good" at minute 2, "Blood work is in progress" at minute 5, and "Full results ready" at minute 20. Each piece of information arrives as soon as it's available — not after everything is done.
A well-built app, like a well-run hospital, never goes fully dark. Something is always visible, always responsive, always communicating status to the user.
loading.js — Automatic Suspense Boundaries
In the Next.js App Router, creating a file called loading.js (or loading.tsx) in any route folder automatically wraps that route's page.js in a React Suspense boundary. The loading file renders immediately while the page's async content is being fetched.
How It Works Under the Hood
When you create a loading.js file, Next.js essentially transforms your route into this structure:
// What Next.js does internally when it finds loading.js
// You never write this — Next.js generates it from your file structure
import { Suspense } from 'react';
import Loading from './loading';
import Page from './page';
export default function RouteWrapper() {
return (
<Suspense fallback={<Loading />}>
<Page />
</Suspense>
);
}
// The layout ABOVE this route is NOT wrapped in Suspense
// It renders immediately — only the page content shows the loading state
Basic Loading State
// app/dashboard/loading.tsx
// This file renders INSTANTLY when the user navigates to /dashboard
// It stays visible until the page.tsx component finishes its async work
export default function DashboardLoading() {
return (
<div className="animate-pulse space-y-4 p-6">
<div className="h-8 w-48 rounded bg-gray-700" />
<div className="grid grid-cols-3 gap-4">
<div className="h-32 rounded bg-gray-700" />
<div className="h-32 rounded bg-gray-700" />
<div className="h-32 rounded bg-gray-700" />
</div>
<div className="h-64 rounded bg-gray-700" />
</div>
);
}
// When the user clicks a link to /dashboard:
// 1. The layout renders immediately (header, sidebar stay visible)
// 2. This loading skeleton appears in the page area
// 3. dashboard/page.tsx fetches data in the background
// 4. When data is ready, the skeleton is replaced with real content
The Page That Triggers the Loading State
// app/dashboard/page.tsx
async function getDashboardData() {
// This takes 2-3 seconds — without loading.js, the user sees nothing
const res = await fetch('https://api.example.com/dashboard', {
cache: 'no-store',
});
return res.json();
}
export default async function DashboardPage() {
// This await is what triggers the Suspense boundary
// loading.tsx shows while this promise is pending
const data = await getDashboardData();
return (
<div className="p-6">
<h1 className="text-2xl font-bold">Welcome, {data.user.name}</h1>
<div className="grid grid-cols-3 gap-4 mt-4">
<StatCard title="Revenue" value={data.revenue} />
<StatCard title="Users" value={data.users} />
<StatCard title="Orders" value={data.orders} />
</div>
<RecentActivity items={data.recentActivity} />
</div>
);
}
Skeleton UI Best Practices
A good skeleton mimics the exact layout of the real content. Users perceive skeleton UIs as 30-40% faster than spinners because the skeleton sets an expectation of what's coming:
// app/products/loading.tsx
// Good skeleton: matches the real page layout
export default function ProductsLoading() {
return (
<div className="p-6">
{/* Title skeleton — same size as the real h1 */}
<div className="h-8 w-64 rounded bg-gray-700 animate-pulse mb-6" />
{/* Filter bar skeleton — same width and position as real filters */}
<div className="flex gap-2 mb-4">
<div className="h-10 w-24 rounded bg-gray-700 animate-pulse" />
<div className="h-10 w-24 rounded bg-gray-700 animate-pulse" />
<div className="h-10 w-24 rounded bg-gray-700 animate-pulse" />
</div>
{/* Product grid skeleton — same grid layout as real products */}
<div className="grid grid-cols-4 gap-4">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="space-y-2">
{/* Image placeholder */}
<div className="h-48 rounded bg-gray-700 animate-pulse" />
{/* Product name */}
<div className="h-4 w-3/4 rounded bg-gray-700 animate-pulse" />
{/* Price */}
<div className="h-4 w-1/4 rounded bg-gray-700 animate-pulse" />
</div>
))}
</div>
</div>
);
}
// Bad skeleton: a centered spinner that tells the user nothing about
// what's coming. Avoid this pattern — it gives no spatial context.
// export default function Loading() {
// return <div className="flex justify-center p-20"><Spinner /></div>;
// }
error.js — Per-Route Error Boundaries
Creating an error.js file in a route folder automatically wraps that route's content in a React error boundary. When an error occurs during rendering or data fetching, the error UI replaces only that segment — the rest of the app continues to work.
Critical Rule: error.js Must Be a Client Component
Error boundaries in React require class component lifecycle methods (componentDidCatch). Even though Next.js abstracts this away, the error.js file must include the 'use client' directive:
// app/dashboard/error.tsx
'use client'; // REQUIRED — error boundaries must be client components
import { useEffect } from 'react';
export default function DashboardError({
error,
reset,
}: {
error: Error & { digest?: string }; // digest is a server-safe error hash
reset: () => void; // function to retry rendering the route segment
}) {
useEffect(() => {
// Log the error to your error tracking service (Sentry, etc.)
console.error('Dashboard error:', error);
}, [error]);
return (
<div className="p-6 text-center">
<h2 className="text-xl font-bold text-red-400">
Something went wrong loading your dashboard
</h2>
<p className="text-gray-400 mt-2">
{error.message || 'An unexpected error occurred'}
</p>
<button
onClick={() => reset()} // Re-renders the route segment (retries data fetching)
className="mt-4 px-4 py-2 bg-emerald-600 text-white rounded hover:bg-emerald-700"
>
Try again
</button>
</div>
);
}
// How error.js wraps your route (conceptual):
// <ErrorBoundary fallback={<DashboardError />}>
// <Suspense fallback={<Loading />}>
// <Page />
// </Suspense>
// </ErrorBoundary>
//
// Notice: ErrorBoundary wraps OUTSIDE Suspense
// This means errors during data fetching ARE caught
Error Boundary Hierarchy
app/
layout.tsx <-- NOT caught by app/error.tsx (needs global-error.tsx)
error.tsx <-- catches errors in app/page.tsx
page.tsx
dashboard/
layout.tsx <-- NOT caught by dashboard/error.tsx
error.tsx <-- catches errors in dashboard/page.tsx and children
page.tsx
settings/
error.tsx <-- catches errors in settings/page.tsx only
page.tsx
Key interview point: An error.js file catches errors in its sibling page.js and all child segments, but it does NOT catch errors in its sibling layout.js. To catch layout errors, you need an error.js in the parent segment. To catch root layout errors, you need global-error.js.
Global Error Boundary
// app/global-error.tsx
'use client'; // Required
// global-error.tsx catches errors in the ROOT layout
// It REPLACES the entire page, so it must include <html> and <body>
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<html>
<body className="bg-gray-900 text-white flex items-center justify-center min-h-screen">
<div className="text-center">
<h1 className="text-3xl font-bold text-red-400">
Something went critically wrong
</h1>
<p className="text-gray-400 mt-2">{error.message}</p>
<button
onClick={() => reset()}
className="mt-4 px-6 py-3 bg-emerald-600 rounded text-white"
>
Restart application
</button>
</div>
</body>
</html>
);
}
// global-error.tsx is the ONLY error boundary that catches root layout errors
// It must render its own <html> and <body> because the root layout has failed
// In production, error.digest is a hash (sensitive info is stripped from the client)
not-found.js — Handling Missing Content
Next.js provides two levels of "not found" handling:
App-Level Not Found
// app/not-found.tsx
// This renders when:
// 1. A user visits a URL that doesn't match any route
// 2. A server component calls notFound() without a closer not-found.tsx
export default function NotFound() {
return (
<div className="flex flex-col items-center justify-center min-h-[60vh]">
<h1 className="text-6xl font-bold text-gray-400">404</h1>
<h2 className="text-xl text-gray-500 mt-4">Page not found</h2>
<p className="text-gray-600 mt-2">
The page you're looking for doesn't exist or has been moved.
</p>
<a
href="/"
className="mt-6 px-4 py-2 bg-emerald-600 text-white rounded hover:bg-emerald-700"
>
Go home
</a>
</div>
);
}
Route-Level Not Found with notFound()
// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation';
async function getPost(slug: string) {
const res = await fetch(`https://api.example.com/posts/${slug}`);
if (!res.ok) return null;
return res.json();
}
export default async function BlogPost({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const post = await getPost(slug);
// If the post doesn't exist, trigger the nearest not-found boundary
if (!post) {
notFound(); // Throws a NEXT_NOT_FOUND error caught by not-found.tsx
}
return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
</article>
);
}
// app/blog/[slug]/not-found.tsx
// This is a ROUTE-LEVEL not-found — only shows for /blog/[slug]
// It's more specific and helpful than the generic app-level 404
export default function BlogPostNotFound() {
return (
<div className="p-6 text-center">
<h2 className="text-2xl font-bold text-gray-400">Post not found</h2>
<p className="text-gray-500 mt-2">
This blog post doesn't exist or has been removed.
</p>
<a
href="/blog"
className="mt-4 inline-block text-emerald-400 hover:underline"
>
Browse all posts
</a>
</div>
);
}
Streaming with Suspense — Piece-by-Piece Rendering
Streaming is the mechanism that makes loading.js work, but you can use it at a more granular level with React's <Suspense> component directly. Instead of waiting for the entire page to be ready, the server streams HTML to the browser in chunks as each piece becomes ready.
Why Streaming Matters
Without streaming, a page with three data sources that take 1s, 2s, and 3s would make the user wait 3 seconds for the entire page. With streaming, the 1s content appears at 1s, the 2s content appears at 2s, and the 3s content appears at 3s. The user sees progressive content rather than an all-or-nothing page load.
Without streaming (traditional SSR):
[--------- 3s wait ---------] [Full page appears]
With streaming:
[-- 1s --] [Section A appears]
[---- 2s ----] [Section B appears]
[------- 3s -------] [Section C appears]
Granular Suspense Boundaries
// app/dashboard/page.tsx
import { Suspense } from 'react';
// Each component fetches its own data independently
async function RevenueChart() {
const data = await fetch('https://api.example.com/revenue', {
cache: 'no-store',
});
const revenue = await data.json();
return <div className="h-64 bg-gray-800 rounded p-4">
<h3>Revenue: ${revenue.total}</h3>
{/* Chart rendering */}
</div>;
}
async function RecentOrders() {
const data = await fetch('https://api.example.com/orders', {
cache: 'no-store',
});
const orders = await data.json();
return (
<ul>
{orders.map((order: { id: string; item: string }) => (
<li key={order.id}>{order.item}</li>
))}
</ul>
);
}
async function UserStats() {
const data = await fetch('https://api.example.com/stats', {
cache: 'no-store',
});
const stats = await data.json();
return <div>Active users: {stats.activeUsers}</div>;
}
// The page uses Suspense to stream each section independently
export default function DashboardPage() {
return (
<div className="p-6 space-y-6">
<h1 className="text-2xl font-bold">Dashboard</h1>
{/* Each Suspense boundary streams independently */}
{/* Fast API (200ms) — appears first */}
<Suspense fallback={<div className="h-12 bg-gray-700 animate-pulse rounded" />}>
<UserStats />
</Suspense>
{/* Medium API (1s) — appears second */}
<Suspense fallback={<div className="h-64 bg-gray-700 animate-pulse rounded" />}>
<RevenueChart />
</Suspense>
{/* Slow API (3s) — appears last */}
<Suspense fallback={
<div className="space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="h-8 bg-gray-700 animate-pulse rounded" />
))}
</div>
}>
<RecentOrders />
</Suspense>
</div>
);
}
// Result: The page loads progressively
// 0ms — User sees the h1 + all three skeleton fallbacks
// 200ms — UserStats skeleton is replaced with real stats
// 1000ms — RevenueChart skeleton is replaced with real chart
// 3000ms — RecentOrders skeleton is replaced with real orders
//
// Without Suspense boundaries, the user sees NOTHING for 3 seconds
Nested Loading States — Granularity Without Chaos
In a real app, routes are nested. A dashboard has sub-pages. Each can have its own loading.js, and they compose naturally:
app/
layout.tsx <-- Always visible (header, nav)
dashboard/
layout.tsx <-- Dashboard sidebar (always visible once loaded)
loading.tsx <-- Shows while dashboard/page.tsx loads
page.tsx <-- Dashboard overview
analytics/
loading.tsx <-- Shows while analytics/page.tsx loads
page.tsx <-- Analytics sub-page
settings/
loading.tsx <-- Shows while settings/page.tsx loads
page.tsx <-- Settings sub-page
When the user navigates from /dashboard to /dashboard/analytics:
- The root layout stays rendered (header, navigation)
- The dashboard layout stays rendered (sidebar)
- Only the
analytics/loading.tsxappears in the content area - When the analytics page is ready, it replaces the loading state
This is the power of nested loading states: the user never loses context. The header, sidebar, and navigation remain interactive while only the changed segment shows a loading state.
// app/dashboard/layout.tsx
// This layout PERSISTS across all dashboard sub-page navigations
// It never re-renders or shows a loading state when children change
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="flex min-h-screen">
{/* Sidebar — always visible, never blocked by child loading states */}
<aside className="w-64 bg-gray-800 p-4">
<nav>
<a href="/dashboard" className="block py-2 text-gray-300 hover:text-white">
Overview
</a>
<a href="/dashboard/analytics" className="block py-2 text-gray-300 hover:text-white">
Analytics
</a>
<a href="/dashboard/settings" className="block py-2 text-gray-300 hover:text-white">
Settings
</a>
</nav>
</aside>
{/* This is where loading.tsx and page.tsx render */}
<main className="flex-1 p-6">{children}</main>
</div>
);
}
// app/dashboard/analytics/loading.tsx
// This ONLY shows in the <main> area of the dashboard layout
// The sidebar remains fully visible and interactive
export default function AnalyticsLoading() {
return (
<div className="space-y-4 animate-pulse">
<div className="h-8 w-40 bg-gray-700 rounded" />
<div className="grid grid-cols-2 gap-4">
<div className="h-48 bg-gray-700 rounded" />
<div className="h-48 bg-gray-700 rounded" />
</div>
<div className="h-96 bg-gray-700 rounded" />
</div>
);
}
Combining loading.js with Inline Suspense
You can use both loading.js (for the route-level boundary) and <Suspense> (for granular section-level boundaries) in the same route. The loading.js wraps the entire page, while inline <Suspense> boundaries handle individual sections within the page:
// app/dashboard/analytics/page.tsx
import { Suspense } from 'react';
// loading.tsx handles the initial page load
// Once the page component renders, inline Suspense handles individual sections
async function TrafficChart() {
const data = await fetch('https://api.example.com/traffic');
const traffic = await data.json();
return <div>Traffic: {traffic.visitors} visitors</div>;
}
async function ConversionRate() {
// This API is slow — 5 seconds
const data = await fetch('https://api.example.com/conversions');
const conversions = await data.json();
return <div>Conversion rate: {conversions.rate}%</div>;
}
export default function AnalyticsPage() {
// By the time this renders, loading.tsx has already been replaced
// Now inline Suspense handles the individual async components
return (
<div>
<h1 className="text-2xl font-bold">Analytics</h1>
<Suspense fallback={<div className="h-48 bg-gray-700 animate-pulse rounded" />}>
<TrafficChart />
</Suspense>
<Suspense fallback={<div className="h-48 bg-gray-700 animate-pulse rounded" />}>
<ConversionRate />
</Suspense>
</div>
);
}
The Special Files Summary — How They Compose
Here is the complete hierarchy of special files and how Next.js composes them into the rendered output:
<Layout> // layout.tsx — persists, no re-render
<ErrorBoundary fallback={ // error.tsx — catches page + child errors
<Error />
}>
<Suspense fallback={ // loading.tsx — shows while page loads
<Loading />
}>
<Page /> // page.tsx — the actual content
{/* or */}
<NotFound /> // not-found.tsx — when notFound() is called
</Suspense>
</ErrorBoundary>
</Layout>
// Key relationships:
// - Layout wraps everything and persists across navigations
// - ErrorBoundary is OUTSIDE Suspense (catches errors during loading too)
// - Loading is INSIDE ErrorBoundary (errors replace the loading state)
// - NotFound renders in the same slot as Page
Common Mistakes
-
Not creating
loading.jsat all. Without it, navigating to a page with async data shows a completely blank content area until data arrives. Users perceive this as a broken app. Always create at least a basic skeleton for routes that fetch data. -
Making
error.jsa Server Component. Error boundaries must be client components. Forgetting the'use client'directive at the top oferror.jscauses a build error. This is the most common mistake developers make with error boundaries in Next.js. -
Using a single Suspense boundary for the entire page. If your page has five data-fetching components wrapped in one Suspense boundary, the user waits for the slowest one before seeing anything. Use multiple Suspense boundaries so faster sections appear first. Think granular, not global.
-
Forgetting that
error.jsdoesn't catch layout errors. Anerror.jsin/dashboard/error.tsxcatches errors from/dashboard/page.tsx, but NOT from/dashboard/layout.tsx. Layout errors propagate to the parent segment's error boundary. This catches many developers off guard in production. -
Putting
not-found.jsonly at the app level. A generic "Page not found" is unhelpful when a user visits/blog/nonexistent-post. Create route-specificnot-found.tsxfiles that guide users back to relevant content (like "Browse all posts" instead of just "Go home").
Interview Questions
1. What does loading.js do in the Next.js App Router, and how does it relate to React Suspense?
A loading.js file in a route segment automatically wraps the corresponding page.js in a React <Suspense> boundary. When the page's async Server Component is fetching data, the loading file renders immediately as the fallback UI. Once the page component resolves, it replaces the loading state. This is a file-convention shortcut for manually writing <Suspense fallback={<Loading />}><Page /></Suspense>. The layout above the route is NOT wrapped — it renders immediately and remains visible during the loading state.
2. Why must error.js include 'use client', and what are its two props?
React error boundaries require class component lifecycle methods (componentDidCatch, getDerivedStateFromError), which only exist in the client runtime. Next.js abstracts this with a function component API but still requires the 'use client' directive. The two props are: error (an Error object with an optional digest property — a server-safe hash in production) and reset (a function that retries rendering the route segment, useful for transient errors like network failures).
3. Explain the difference between error.js and global-error.js. When would you need global-error.js?
error.js catches errors in its sibling page.js and all child route segments, but it does NOT catch errors in its sibling layout.js. This is because the error boundary sits inside the layout in the component hierarchy. global-error.js is the only boundary that catches errors in the root layout.tsx. It must render its own <html> and <body> tags because the root layout — which normally provides those tags — has failed. You need global-error.js to prevent a completely broken page when the root layout crashes.
4. How does streaming with Suspense improve perceived performance, and how would you implement it for a dashboard with slow and fast data sources?
Without streaming, the server waits until ALL data is fetched before sending any HTML. With streaming, the server sends HTML chunks as each piece becomes ready. For a dashboard with a fast API (200ms) and a slow API (3s), you wrap each section in its own <Suspense> boundary with a skeleton fallback. The fast section appears at 200ms while the slow section shows a skeleton. At 3s, the slow section streams in and replaces its skeleton. The user gets progressive feedback instead of staring at nothing for 3 seconds. Each <Suspense> boundary acts as an independent streaming point.
5. A user reports that navigating between /dashboard/analytics and /dashboard/settings causes the entire sidebar to disappear and show a loading spinner. What's wrong and how would you fix it?
The loading.js is likely placed in the wrong location. If it's in the dashboard/ folder, it wraps the entire dashboard layout content — including the sidebar. The fix is to move loading.js into each sub-route folder (dashboard/analytics/loading.tsx and dashboard/settings/loading.tsx). This way, only the content area within the dashboard layout shows a loading state, while the sidebar (rendered by dashboard/layout.tsx) remains visible and interactive. Alternatively, ensure the sidebar is part of the dashboard layout (not the page) so it persists across navigations.
Quick Reference -- Cheat Sheet
| Concept | Key Point |
|---|---|
loading.js | Auto Suspense boundary per route — shows while page.js loads |
error.js | Auto error boundary per route — must be 'use client' |
global-error.js | Catches root layout errors — must render <html> and <body> |
not-found.js | Renders for unmatched URLs or when notFound() is called |
notFound() | Import from next/navigation — triggers nearest not-found.js |
| Streaming | Server sends HTML in chunks as each Suspense boundary resolves |
| Skeleton UI | Loading placeholder that mimics real content layout (not a spinner) |
| Nested loading | Each route segment can have its own loading state — layouts persist |
| Error hierarchy | error.js catches page + children, NOT sibling layout |
reset() | Error boundary prop that retries rendering the failed segment |
error.digest | Server-safe error hash — sensitive details stripped in production |
+-----------------------------------------------+
| Loading & Error Mental Model |
+-----------------------------------------------+
| |
| File hierarchy (inner to outer): |
| |
| <Layout> -- always visible |
| <ErrorBoundary> -- error.tsx |
| <Suspense> -- loading.tsx |
| <Page /> -- page.tsx |
| </Suspense> |
| </ErrorBoundary> |
| </Layout> |
| |
| Key rules: |
| - error.js = 'use client' (always) |
| - error.js catches page, NOT layout |
| - loading.js = auto Suspense fallback |
| - Streaming = progressive HTML chunks |
| - Skeleton > Spinner (user perception) |
| - Nest loading states for partial updates |
| |
+-----------------------------------------------+
Previous: Lesson 4.2 -- Caching & Revalidation Next: Lesson 4.4 -- Server Actions for Mutations
This is Lesson 4.3 of the Next.js Interview Prep Course -- 8 chapters, 33 lessons.