---
title: "Error Handling & Loading"
description: "Add loading.tsx files for Suspense boundaries, handle auth errors gracefully, and create consistent error patterns throughout the application."
canonical_url: "https://vercel.com/academy/subscription-store/error-handling-and-loading-states"
md_url: "https://vercel.com/academy/subscription-store/error-handling-and-loading-states.md"
docset_id: "vercel-academy"
doc_version: "1.0"
last_updated: "2026-04-11T18:55:41.907Z"
content_type: "lesson"
course: "subscription-store"
course_title: "Launch a Subscription Store with Vercel and Stripe"
prerequisites:  []
---

<agent-instructions>
Vercel Academy — structured learning, not reference docs.
Lessons are sequenced.
Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume.
The lesson shows one path; if the human's project diverges, adapt concepts to their setup.
Preserve the learning goal over literal steps.
Quizzes are pedagogical — engage, don't spoil.
Quiz answers are included for your reference.
</agent-instructions>

# Error Handling & Loading

# 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

```bash
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`:

```typescript title="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`:

```typescript title="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`:

```typescript title="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`:

```typescript title="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` (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


---

[Full course index](/academy/llms.txt) · [Sitemap](/academy/sitemap.md)
