Vercel Logo

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

  1. Add loading.tsx to /protected and /protected/account
  2. Create app/protected/error.tsx error boundary
  3. Create app/error.tsx global error boundary

Hands-on Exercise 4.1

Add comprehensive loading and error states:

Requirements:

  1. Audit protected routes for missing loading.tsx files
  2. Add loading skeletons to routes without them
  3. Create error boundary for protected area
  4. Create global error boundary as fallback
  5. 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 reset function
  • Match skeleton shapes to actual content layout
  • Error boundaries catch render errors, not event handler errors

Try It

  1. Test loading states:

    • Add await new Promise(r => setTimeout(r, 2000)) to a page
    • Refresh and verify skeleton appears
    • Remove the delay when done
  2. 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
  3. 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:

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:

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:

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:

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

LocationPatternHandles
Server ComponentReturn error JSXData fetch failures
Client Componenttry/catch + stateEvent handler errors
error.tsxError boundaryRender errors
API RouteReturn error ResponseRequest failures
Server ActionReturn { 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 (not Loading.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 reset function re-renders the segment
  • If the error source persists, error will recur
  • For persistent errors, navigate away instead