Error Handling and Loading States
Users notice errors before features. A blank screen during data fetch or a cryptic "Something went wrong" message erodes trust faster than a missing feature. Production-ready apps handle the unhappy path as carefully as the happy one.
Outcome
Add loading skeletons to all protected routes and implement consistent error handling patterns.
Fast Track
- Add
loading.tsxto/protectedand/protected/account - Create
app/protected/error.tsxerror boundary - Create
app/error.tsxglobal error boundary
Hands-on Exercise 4.1
Add comprehensive loading and error states:
Requirements:
- Audit protected routes for missing
loading.tsxfiles - Add loading skeletons to routes without them
- Create error boundary for protected area
- Create global error boundary as fallback
- Ensure all async operations have error handling
Implementation hints:
- Loading files use the skeleton pattern:
bg-muted animate-pulse rounded - Error boundaries are client components with
resetfunction - Match skeleton shapes to actual content layout
- Error boundaries catch render errors, not event handler errors
Try It
-
Test loading states:
- Add
await new Promise(r => setTimeout(r, 2000))to a page - Refresh and verify skeleton appears
- Remove the delay when done
- Add
-
Test error boundary:
- Temporarily throw an error in a Server Component
- Verify error boundary catches it and shows recovery UI
- Remove the error when done
-
Verify all routes:
/protected → loading.tsx ✓ /protected/pricing → loading.tsx ✓ /protected/subscription → loading.tsx ✓ /protected/paid-content → loading.tsx ✓
Commit
git add -A
git commit -m "feat(ux): add error boundaries and loading states"Done-When
- All protected routes have loading.tsx files
- Protected area has error.tsx boundary
- Global error.tsx catches uncaught errors
- Loading skeletons match content layout
- Error boundaries offer recovery action
Solution
Step 1: Audit Existing Loading States
Check which routes already have loading files:
app/protected/
├── pricing/
│ └── loading.tsx ✓ exists
├── subscription/
│ └── loading.tsx ✓ exists
├── paid-content/
│ └── loading.tsx ✓ exists
├── page.tsx ✗ needs loading.tsx
└── layout.tsx
Step 2: Add Protected Root Loading
Create app/protected/loading.tsx:
export default function Loading() {
return (
<div className="space-y-8">
<div className="flex items-center justify-between">
<div>
<div className="h-8 w-32 bg-muted animate-pulse rounded" />
<div className="h-4 w-48 bg-muted animate-pulse rounded mt-2" />
</div>
<div className="h-10 w-24 bg-muted animate-pulse rounded" />
</div>
<div className="border rounded-lg p-6 space-y-4">
<div className="h-5 w-36 bg-muted animate-pulse rounded" />
<div className="grid gap-2">
<div className="grid grid-cols-[120px_1fr] gap-2">
<div className="h-4 bg-muted animate-pulse rounded" />
<div className="h-4 bg-muted animate-pulse rounded w-48" />
</div>
<div className="grid grid-cols-[120px_1fr] gap-2">
<div className="h-4 bg-muted animate-pulse rounded" />
<div className="h-4 bg-muted animate-pulse rounded w-64" />
</div>
<div className="grid grid-cols-[120px_1fr] gap-2">
<div className="h-4 bg-muted animate-pulse rounded" />
<div className="h-4 bg-muted animate-pulse rounded w-40" />
</div>
</div>
</div>
<div className="border rounded-lg p-6 space-y-4">
<div className="h-5 w-44 bg-muted animate-pulse rounded" />
<div className="grid gap-2">
<div className="grid grid-cols-[120px_1fr] gap-2">
<div className="h-4 bg-muted animate-pulse rounded" />
<div className="h-4 bg-muted animate-pulse rounded w-24" />
</div>
<div className="grid grid-cols-[120px_1fr] gap-2">
<div className="h-4 bg-muted animate-pulse rounded" />
<div className="h-4 bg-muted animate-pulse rounded w-16" />
</div>
</div>
</div>
</div>
);
}This skeleton matches the account page layout with its two info cards.
Step 3: Create Protected Error Boundary
Create app/protected/error.tsx:
"use client";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
export default function ProtectedError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div className="flex min-h-[400px] items-center justify-center">
<Card className="p-6 text-center max-w-md">
<h2 className="text-xl font-semibold text-destructive">
Something went wrong
</h2>
<p className="mt-2 text-muted-foreground">
We couldn't load this page. This might be a temporary issue.
</p>
{error.digest && (
<p className="mt-2 text-xs text-muted-foreground font-mono">
Error ID: {error.digest}
</p>
)}
<div className="mt-4 flex gap-2 justify-center">
<Button onClick={reset}>Try again</Button>
<Button variant="outline" asChild>
<a href="/protected">Go to Account</a>
</Button>
</div>
</Card>
</div>
);
}Step 4: Create Global Error Boundary
Create app/error.tsx:
"use client";
import { Button } from "@/components/ui/button";
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div className="min-h-screen flex items-center justify-center p-4">
<div className="text-center max-w-md">
<h1 className="text-2xl font-semibold">Something went wrong</h1>
<p className="mt-2 text-muted-foreground">
An unexpected error occurred. Please try again.
</p>
{error.digest && (
<p className="mt-2 text-xs text-muted-foreground font-mono">
Error ID: {error.digest}
</p>
)}
<div className="mt-4 flex gap-2 justify-center">
<Button onClick={reset}>Try again</Button>
<Button variant="outline" asChild>
<a href="/">Go home</a>
</Button>
</div>
</div>
</div>
);
}Step 5: Create Global Not Found Page
Create app/not-found.tsx:
import { Button } from "@/components/ui/button";
import Link from "next/link";
export default function NotFound() {
return (
<div className="min-h-screen flex items-center justify-center p-4">
<div className="text-center max-w-md">
<h1 className="text-6xl font-bold text-muted-foreground">404</h1>
<h2 className="mt-4 text-xl font-semibold">Page not found</h2>
<p className="mt-2 text-muted-foreground">
The page you're looking for doesn't exist or has been moved.
</p>
<Button className="mt-4" asChild>
<Link href="/">Go home</Link>
</Button>
</div>
</div>
);
}How Error Boundaries Work
Component throws error during render
↓
React looks for nearest error.tsx
↓
Found → Render error UI with reset function
Not found → Bubble up to parent
↓
Eventually hits app/error.tsx (global)
↓
User clicks "Try again"
↓
reset() re-renders the component tree
Error boundaries only catch:
- Errors during rendering
- Errors in lifecycle methods
- Errors in constructors
They don't catch:
- Event handler errors (use try/catch)
- Async errors in callbacks (use try/catch)
- Server-side errors (handled differently)
Loading State Hierarchy
User navigates to /protected/subscription
↓
Next.js checks for loading.tsx
↓
/protected/subscription/loading.tsx exists?
Yes → Show subscription skeleton
No → Check parent /protected/loading.tsx
↓
Parent loading.tsx exists?
Yes → Show protected skeleton
No → Check app/loading.tsx (global)
Each route segment can have its own loading state, or inherit from parent.
Error Handling Patterns Summary
| Location | Pattern | Handles |
|---|---|---|
| Server Component | Return error JSX | Data fetch failures |
| Client Component | try/catch + state | Event handler errors |
| error.tsx | Error boundary | Render errors |
| API Route | Return error Response | Request failures |
| Server Action | Return { error } | Form submission errors |
File Structure After This Lesson
app/
├── error.tsx ← New: global error boundary
├── not-found.tsx ← New: 404 page
├── protected/
│ ├── error.tsx ← New: protected error boundary
│ ├── loading.tsx ← New: protected loading skeleton
│ ├── page.tsx
│ ├── pricing/
│ │ └── loading.tsx ← Already exists
│ ├── subscription/
│ │ └── loading.tsx ← Already exists
│ └── paid-content/
│ └── loading.tsx ← Already exists
Troubleshooting
Loading skeleton doesn't appear:
- The data fetch might be too fast
- Add artificial delay to test:
await new Promise(r => setTimeout(r, 2000)) - Verify loading.tsx is in the correct directory
- Check file is named exactly
loading.tsx(notLoading.tsx)
Error boundary doesn't catch error:
- Error boundaries only catch render errors
- Event handler errors need try/catch
- Server-side errors are handled separately
- Check if error is happening in a client component without boundary
Reset button doesn't work:
- The
resetfunction re-renders the segment - If the error source persists, error will recur
- For persistent errors, navigate away instead
Was this helpful?