Skip to main content

Command Palette

Search for a command to run...

Designing Your API Layer โ€” Services, Interceptors, and Error Handling

Day 19 of 40 โ€” React System Design Series

Updated
โ€ข8 min read
Designing Your API Layer โ€” Services, Interceptors, and Error Handling
R

Hi, Iโ€™m Richa โ€” a Senior Frontend Engineer with 5+ years of experience building scalable, production-grade web interfaces for enterprise and consumer applications. I work primarily with React, TypeScript, and modern frontend architectures, focusing on component systems, performance, and maintainability. Most of my experience comes from building real-world products in regulated domains like banking and insurance, where clarity, reliability, and long-term ownership matter more than quick demos. Through this blog, I write about frontend engineering fundamentals, scalable UI design, problem-solving, and the lessons Iโ€™ve learned working on large codebases. My goal is to share practical insights โ€” not shortcuts โ€” for developers who want to grow strong engineering foundations. I also mentor early-career developers and strongly believe that curiosity, asking the right questions, and understanding why something works are more important than memorizing tools. If youโ€™re serious about improving as an engineer, youโ€™re in the right place.

Hey everyone, Richa here! ๐Ÿ‘‹

api gif

Day 19. And today we talk about the layer that almost every tutorial skips entirely.

The API layer.

I've seen React components that call fetch('/api/users') directly. I've seen the same API URL typed five times in five different files. I've seen error handling copy-pasted into every single component โ€” slightly different each time. I've seen apps where changing the base URL for staging vs production meant finding and updating 40 files.

These are all symptoms of the same problem: no proper API layer.

Today we design one properly.

api gif

Why you need an API layer

Your React app talks to a backend. Every API call needs:

  • The correct base URL (different in dev, staging, production)

  • Authentication headers or cookies attached

  • A consistent way to handle errors

  • A consistent way to handle loading states

  • A place to handle token refresh (Day 15)

  • Logging for debugging

Without an API layer, all of this is scattered across your components and hooks. With one โ€” it's centralised, consistent, and easy to change.

api gif

The three-layer architecture

Component / Hook
      โ†“
  Service (domain logic โ€” what to call and with what data)
      โ†“
  apiClient (HTTP transport โ€” how to call it)
      โ†“
  Backend API

Each layer has one responsibility:

  • apiClient โ€” base URL, auth, interceptors, error normalisation

  • Service โ€” domain-specific methods (getUser, createOrder, updateProduct)

  • Component/Hook โ€” calling the service, managing UI state


Step 1 โ€” The apiClient

We built the basics on Day 12. Let's make it production-complete:

// services/apiClient.ts
import axios, { AxiosError, type AxiosInstance } from 'axios';

// Environment-aware base URL
const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? '/api';

export const apiClient: AxiosInstance = axios.create({
  baseURL: BASE_URL,
  timeout: 10_000,           // 10 second timeout โ€” fail fast
  withCredentials: true,     // send cookies automatically
  headers: {
    'Content-Type': 'application/json',
    'Accept': 'application/json',
  },
});

// Request interceptor โ€” add any request-level headers
apiClient.interceptors.request.use(
  (config) => {
    // Could add request ID for tracing, locale header, etc.
    config.headers['X-Request-ID'] = crypto.randomUUID();
    return config;
  },
  (error) => Promise.reject(error)
);

// Response interceptor โ€” normalise errors + handle 401
apiClient.interceptors.response.use(
  (response) => response,
  async (error: AxiosError) => {
    // Handle 401 with token refresh (from Day 15 โ€” abbreviated here)
    if (error.response?.status === 401) {
      // ... refresh token logic
    }

    // Normalise the error before it reaches service/component code
    throw normaliseApiError(error);
  }
);

api gif

Step 2 โ€” Normalising errors

This is the piece most projects skip. Without normalisation, you get different error shapes from different parts of the stack:

// Without normalisation โ€” you get all of these in different situations:
error.message                    // network error
error.response.data.message      // server error with body
error.response.data.errors[0]    // validation error
error.response.statusText        // HTTP error
'Network Error'                  // axios offline error

With normalisation โ€” you always get the same shape:

// utils/apiError.ts
export class ApiError extends Error {
  constructor(
    public readonly message: string,
    public readonly status: number,
    public readonly code?: string,
    public readonly details?: Record<string, string[]>
  ) {
    super(message);
    this.name = 'ApiError';
  }

  get isUnauthorised() { return this.status === 401; }
  get isForbidden() { return this.status === 403; }
  get isNotFound() { return this.status === 404; }
  get isServerError() { return this.status >= 500; }
  get isNetworkError() { return this.status === 0; }
}

export function normaliseApiError(error: AxiosError): ApiError {
  if (!error.response) {
    // Network error โ€” no response from server
    return new ApiError('Network error. Please check your connection.', 0, 'NETWORK_ERROR');
  }

  const { status, data } = error.response;
  const serverData = data as Record<string, unknown>;

  // Extract message from common server error formats
  const message =
    (serverData?.message as string) ??
    (serverData?.error as string) ??
    error.response.statusText ??
    'Something went wrong';

  // Extract validation errors if present
  const details = serverData?.errors as Record<string, string[]> | undefined;

  // Common error codes
  const code = (serverData?.code as string) ?? undefined;

  return new ApiError(message, status, code, details);
}

Now every error that comes out of apiClient is an ApiError with consistent properties. Components and hooks can handle errors the same way regardless of where they came from:

try {
  await createOrder(orderData);
} catch (error) {
  if (error instanceof ApiError) {
    if (error.isUnauthorised) navigate('/login');
    else if (error.details) setFieldErrors(error.details);
    else setError(error.message);
  }
}

Step 3 โ€” Services (domain-specific API calls)

Each domain has its own service file. Services use apiClient and return typed data:

// services/productService.ts
import { apiClient } from './apiClient';
import type { Product, CreateProductDto, UpdateProductDto, ProductFilters } from '@features/products/types';

export const productService = {
  async getAll(filters?: ProductFilters): Promise<Product[]> {
    const { data } = await apiClient.get<Product[]>('/products', {
      params: filters,
    });
    return data;
  },

  async getById(id: string): Promise<Product> {
    const { data } = await apiClient.get<Product>(`/products/${id}`);
    return data;
  },

  async create(product: CreateProductDto): Promise<Product> {
    const { data } = await apiClient.post<Product>('/products', product);
    return data;
  },

  async update(id: string, updates: UpdateProductDto): Promise<Product> {
    const { data } = await apiClient.patch<Product>(`/products/${id}`, updates);
    return data;
  },

  async delete(id: string): Promise<void> {
    await apiClient.delete(`/products/${id}`);
  },
};

Notice:

  • No try/catch in the service โ€” error normalisation is handled by apiClient's interceptor

  • The method name describes the intent (getAll, create, update) not the HTTP verb

  • Everything is typed โ€” Product[], Product, void

  • Services don't know about React, components, or state โ€” pure API calls


api gif

Step 4 โ€” Plugging services into React Query

Services and React Query are a perfect pairing:

// features/products/hooks/useProducts.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { productService } from '@/services/productService';
import type { CreateProductDto, ProductFilters } from '../types';

// Query keys โ€” centralised so they're consistent
export const productKeys = {
  all: ['products'] as const,
  filtered: (filters: ProductFilters) => ['products', filters] as const,
  detail: (id: string) => ['products', id] as const,
};

export function useProducts(filters?: ProductFilters) {
  return useQuery({
    queryKey: productKeys.filtered(filters ?? {}),
    queryFn: () => productService.getAll(filters),
    staleTime: 1000 * 60 * 2, // 2 minutes
  });
}

export function useProduct(id: string) {
  return useQuery({
    queryKey: productKeys.detail(id),
    queryFn: () => productService.getById(id),
    enabled: !!id,
  });
}

export function useCreateProduct() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (product: CreateProductDto) => productService.create(product),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: productKeys.all });
    },
  });
}

The component calls useProducts(). That calls productService.getAll(). That calls apiClient.get('/products'). Three clean layers. Each testable independently.


api gif

Handling errors in components โ€” the consistent pattern

With ApiError normalised at the apiClient level, error handling in components is clean and consistent:

function CreateProductForm() {
  const createProduct = useCreateProduct();
  const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});

  async function handleSubmit(data: CreateProductDto) {
    setFieldErrors({});

    try {
      await createProduct.mutateAsync(data);
      toast.success('Product created!');
    } catch (error) {
      if (error instanceof ApiError) {
        if (error.details) {
          // Validation errors from the server โ€” show them on the fields
          const flat = Object.fromEntries(
            Object.entries(error.details).map(([k, v]) => [k, v[0]])
          );
          setFieldErrors(flat);
        } else if (error.isServerError) {
          toast.error('Something went wrong on our end. Please try again.');
        } else {
          toast.error(error.message);
        }
      }
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <input name="name" />
      {fieldErrors.name && <span className="error">{fieldErrors.name}</span>}
      {/* ... */}
    </form>
  );
}

Every form in the app handles errors the same way. No error.response.data.errors[0].message chains. No inconsistency.


Environment configuration

Your base URL should never be hardcoded. Use environment variables:

// .env.development
VITE_API_BASE_URL=http://localhost:3001/api

// .env.staging
VITE_API_BASE_URL=https://staging-api.myapp.com/api

// .env.production
VITE_API_BASE_URL=https://api.myapp.com/api
// services/apiClient.ts
const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? '/api';

Change environments without touching a single service or component.


Quick summary โ€” the full API layer

apiClient.ts
  โ”œโ”€โ”€ Base URL from environment variable
  โ”œโ”€โ”€ 10s timeout
  โ”œโ”€โ”€ withCredentials: true
  โ”œโ”€โ”€ Request interceptor (request ID, etc.)
  โ””โ”€โ”€ Response interceptor (401 handling, error normalisation โ†’ ApiError)

productService.ts / orderService.ts / etc.
  โ”œโ”€โ”€ Domain-specific methods (getAll, getById, create, update, delete)
  โ”œโ”€โ”€ Uses apiClient internally
  โ”œโ”€โ”€ Returns typed data
  โ””โ”€โ”€ No try/catch โ€” errors bubble up as ApiError

useProducts / useCreateProduct / etc.
  โ”œโ”€โ”€ Wraps service in React Query (useQuery / useMutation)
  โ”œโ”€โ”€ Manages caching, staleTime, invalidation
  โ””โ”€โ”€ Called by components

Components
  โ”œโ”€โ”€ Calls hooks
  โ”œโ”€โ”€ Handles ApiError consistently
  โ””โ”€โ”€ No direct fetch/axios calls

Today's takeaway

api gif

An API layer isn't about over-engineering. It's about having one place to change the base URL, one place to handle auth errors, one place to normalise error shapes.

Without it โ€” you're copying authentication logic into every service call, changing URLs in 40 places, and writing slightly different error handling every time.

With it โ€” you change one thing. Everything updates.

api done gif

See you tomorrow for Day 20 โ€” Monorepo basics: when and why teams move to monorepos โ€” and when they shouldn't.

api gif

โ† Day 18 โ€” Designing Custom Hooksโ†’ Day 20 โ€” Monorepo Basics


Part of the React System Design Series โ€” 40 days, one topic per day.

React System Design โ€” Learning in Public

Part 19 of 36

I'm a React developer with 5.5 years of experience who spent 2 years in Lit.js โ€” and slowly forgot how React really works at a senior level. This series is my public journey back. Every day I cover one topic: state management, authentication, performance, system design interviews, testing โ€” the real stuff that separates a mid-level dev from a senior. Written honestly, with real code, for developers at every level.

Up next

Monorepo Basics โ€” When and Why Teams Move to Monorepos

Day 20 of 40 โ€” React System Design Series