Back to Blog

How Should I Handle API Errors?

The Problem

Your API returns an error. Now what?

  • Show a toast?
  • Show inline error?
  • Redirect to error page?
  • Retry silently?
  • Log and ignore?

Without a strategy, you end up with inconsistent UX: some errors crash the page, some show cryptic messages, some silently fail.

The Error Categories

CategoryExampleUser Expectation
Validation"Email is invalid"Show inline, let user fix
Auth"Session expired"Redirect to login
Not Found"Product deleted"Show message, offer navigation
Rate Limit"Too many requests"Auto-retry or show wait message
Server Error500, network failureShow generic error, offer retry
OfflineNo connectionShow offline state

The Architecture

┌─────────────────────────────────────────────────────────────┐
│                        API Client                           │
│  ┌─────────────┐  ┌─────────────┐  ┌─────────────────────┐ │
│  │ Fetch/Axios │→ │ Interceptor │→ │ Error Transformer   │ │
│  └─────────────┘  └─────────────┘  └─────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
                              ↓
┌─────────────────────────────────────────────────────────────┐
│                     Error Boundary                          │
│  Catches unhandled errors → Shows fallback UI               │
└─────────────────────────────────────────────────────────────┘
                              ↓
┌─────────────────────────────────────────────────────────────┐
│                    Component Level                          │
│  Handles expected errors → Shows inline feedback            │
└─────────────────────────────────────────────────────────────┘

Step 1: Standardize Error Shape

// types/api.ts
interface ApiError {
  code: string;           // 'VALIDATION_ERROR', 'UNAUTHORIZED', 'NOT_FOUND'
  message: string;        // User-friendly message
  field?: string;         // For validation errors
  details?: unknown;      // Additional context
}

interface ApiResponse<T> {
  data?: T;
  error?: ApiError;
}

Step 2: Create Error-Aware Fetch

// lib/api.ts
class ApiClient {
  private async request<T>(url: string, options?: RequestInit): Promise<T> {
    const response = await fetch(url, {
      ...options,
      headers: {
        'Content-Type': 'application/json',
        ...options?.headers,
      },
    });

    // Handle auth errors globally
    if (response.status === 401) {
      window.location.href = '/login?expired=true';
      throw new Error('Unauthorized');
    }

    const data = await response.json();

    // API returned an error
    if (!response.ok) {
      throw new ApiError(data.error ?? {
        code: 'UNKNOWN',
        message: 'Something went wrong',
      });
    }

    return data;
  }

  get<T>(url: string) {
    return this.request<T>(url);
  }

  post<T>(url: string, body: unknown) {
    return this.request<T>(url, {
      method: 'POST',
      body: JSON.stringify(body),
    });
  }
}

class ApiError extends Error {
  code: string;
  field?: string;

  constructor(error: { code: string; message: string; field?: string }) {
    super(error.message);
    this.code = error.code;
    this.field = error.field;
  }
}

export const api = new ApiClient();

Step 3: Handle at the Right Level

Validation Errors → Inline

function SignupForm() {
  const [errors, setErrors] = useState<Record<string, string>>({});

  async function handleSubmit(formData: FormData) {
    try {
      await api.post('/auth/signup', Object.fromEntries(formData));
      router.push('/dashboard');
    } catch (err) {
      if (err instanceof ApiError && err.code === 'VALIDATION_ERROR') {
        // Show inline on the field
        setErrors({ [err.field!]: err.message });
      } else {
        // Unexpected error — let it bubble to error boundary
        throw err;
      }
    }
  }

  return (
    <form action={handleSubmit}>
      <input name="email" />
      {errors.email && <span className="error">{errors.email}</span>}
      <button type="submit">Sign Up</button>
    </form>
  );
}

Not Found → Show Message

function ProductPage({ params }: { params: { id: string } }) {
  const { data: product, error } = useQuery({
    queryKey: ['product', params.id],
    queryFn: () => api.get(`/products/${params.id}`),
  });

  if (error?.code === 'NOT_FOUND') {
    return (
      <div>
        <h1>Product not found</h1>
        <p>This product may have been removed.</p>
        <Link href="/products">Browse other products</Link>
      </div>
    );
  }

  if (error) throw error; // Unexpected — let error boundary handle

  return <ProductDetails product={product} />;
}

Server/Network Errors → Error Boundary

// app/error.tsx (Next.js)
'use client';

export default function Error({
  error,
  reset,
}: {
  error: Error;
  reset: () => void;
}) {
  return (
    <div>
      <h1>Something went wrong</h1>
      <p>We're working on it. Please try again.</p>
      <button onClick={reset}>Try again</button>
    </div>
  );
}

Offline → Show State

function App() {
  const isOnline = useOnlineStatus();

  if (!isOnline) {
    return (
      <div className="offline-banner">
        You're offline. Some features may not work.
      </div>
    );
  }

  return <MainApp />;
}

function useOnlineStatus() {
  const [isOnline, setIsOnline] = useState(navigator.onLine);

  useEffect(() => {
    const handleOnline = () => setIsOnline(true);
    const handleOffline = () => setIsOnline(false);

    window.addEventListener('online', handleOnline);
    window.addEventListener('offline', handleOffline);

    return () => {
      window.removeEventListener('online', handleOnline);
      window.removeEventListener('offline', handleOffline);
    };
  }, []);

  return isOnline;
}

Step 4: Toast for Background Actions

// For actions that don't have inline feedback location
async function deleteItem(id: string) {
  try {
    await api.delete(`/items/${id}`);
    toast.success('Item deleted');
  } catch (err) {
    toast.error(err.message ?? 'Failed to delete');
  }
}

Decision Matrix

Error TypeStatus CodeHandling
Validation400Inline error on field
Unauthorized401Redirect to login
Forbidden403Show "no access" message
Not Found404Show "not found" UI
Rate Limited429Show "slow down" or retry
Server Error500Error boundary + retry button
Network Error-Offline state or retry

The Anti-Patterns

// ❌ Catching and ignoring
try {
  await api.post('/order', data);
} catch {
  // User has no idea it failed
}

// ❌ Showing technical errors
catch (err) {
  alert(err.message); // "TypeError: Cannot read property 'id' of undefined"
}

// ❌ Every component handling auth
catch (err) {
  if (err.status === 401) router.push('/login'); // Duplicated everywhere
}

TL;DR

  1. Standardize error shape{ code, message, field? }
  2. Handle auth globally — In API client, redirect on 401
  3. Validation inline — Show on the field that failed
  4. Not found / forbidden — Show contextual UI
  5. Server errors — Error boundary with retry
  6. Background actions — Toast notifications
  7. Network errors — Offline state indicator

What did you think?

© 2026 Vidhya Sagar Thakur. All rights reserved.