Designing Your API Layer โ Services, Interceptors, and Error Handling
Day 19 of 40 โ React System Design Series

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! ๐
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.
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.
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 normalisationService โ 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);
}
);
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/catchin the service โ error normalisation is handled byapiClient's interceptorThe method name describes the intent (
getAll,create,update) not the HTTP verbEverything is typed โ
Product[],Product,voidServices don't know about React, components, or state โ pure API calls
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.
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
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.
See you tomorrow for Day 20 โ Monorepo basics: when and why teams move to monorepos โ and when they shouldn't.
โ Day 18 โ Designing Custom Hooksโ Day 20 โ Monorepo Basics
Part of the React System Design Series โ 40 days, one topic per day.






