API Client Architecture
This guide covers the dual API client system: the client-side ApiClient (Axios-based singleton) and the server-side ServerClient (fetch-based with caching and retries), including type safety, error handling, and request/response interceptors.
Architecture Overview
API Client Architecture
=========================
Client-Side (Browser) Server-Side (Node.js)
+------------------------+ +------------------------+
| ApiClient | | ServerClient |
| (lib/api/api-client- | | (lib/api/server-api- |
| class.ts) | | client.ts) |
+------------------------+ +------------------------+
| - Axios-based | | - fetch-based |
| - Singleton pattern | | - Built-in LRU cache |
| - Auth interceptor | | - Retry logic |
| - 401 auto-redirect | | - Timeout handling |
| - Type-safe methods | | - AbortSignal support |
+------------------------+ +------------------------+
| |
v v
+-------------------------------------------------+
| Shared Type System |
| lib/api/types.ts |
| - ApiResponse<T> (discriminated union) |
| - PaginatedResponse<T> |
| - ApiError |
+-------------------------------------------------+
Client-Side API Client
Singleton Pattern
The ApiClient uses a strict singleton managed by ApiClientSingleton:
// lib/api/singleton.ts
class ApiClientSingleton {
private static instance: ApiClient | null = null;
public static getInstance(config?: ApiClientConfig): ApiClient {
if (!ApiClientSingleton.instance) {
ApiClientSingleton.instance = new ApiClient(config);
}
return ApiClientSingleton.instance;
}
public static resetInstance(): void {
ApiClientSingleton.instance = null;
}
}
export const getApiClient = ApiClientSingleton.getInstance;
Usage
// lib/api/api-client.ts
import { getApiClient } from './singleton';
export const apiClient = getApiClient();
// Convenient fetcher functions for React Query
export const fetcherGet = async <T>(endpoint: string, params?: QueryParams): Promise<T> => {
return apiClient.get<T>(endpoint, params);
};
export const fetcherPaginated = async <T>(
endpoint: string,
params: PaginationParams & QueryParams = {}
): Promise<PaginatedResponse<T>> => {
return apiClient.getPaginated<T>(endpoint, params);
};
Request Interceptors
The client automatically attaches the Bearer token to every request:
// lib/api/api-client-class.ts
private tokenInterceptor(): void {
this.client.interceptors.request.use((config) => {
if (this.accessToken) {
config.headers['Authorization'] = `Bearer ${this.accessToken}`;
}
return config;
});
}
Response Interceptors
Automatic 401 handling redirects to the login page:
private handleResponseError = async (error) => {
if (responseError.response?.status === API_CONSTANTS.STATUS.UNAUTHORIZED) {
if (typeof window !== 'undefined' && env.AUTH_ENDPOINT_LOGIN) {
window.location.href = env.AUTH_ENDPOINT_LOGIN;
}
}
throw this.formatError(error);
};
Error Formatting
All errors are normalized to a consistent ApiError shape:
private formatError(error: unknown): ApiError {
if (error instanceof AxiosError && error.response?.data) {
const errorData = error.response.data;
const formattedError = new Error(errorData.message || 'An error occurred');
Object.assign(formattedError, {
code: errorData.code,
details: errorData.details,
status: error.response.status,
});
return formattedError;
}
return new Error(API_CONSTANTS.DEFAULT_ERROR_MESSAGE);
}
Type-Safe Methods
// All methods return unwrapped data (not the full response)
const items = await apiClient.get<Item[]>('/api/items');
const created = await apiClient.post<Item>('/api/items', { title: 'New Item' });
const updated = await apiClient.put<Item>('/api/items/123', { title: 'Updated' });
const patched = await apiClient.patch<Item>('/api/items/123', { status: 'published' });
const deleted = await apiClient.delete<void>('/api/items/123');
// Paginated responses
const page = await apiClient.getPaginated<Item>('/api/items', { page: 1, limit: 20 });
Server-Side API Client
ServerClient Class
The ServerClient in lib/api/server-api-client.ts is optimized for server-side usage:
// lib/api/server-api-client.ts
export class ServerClient {
private baseUrl: string;
private defaultOptions: FetchOptions;
private cacheEnabled: boolean;
constructor(baseUrl: string = '', options: FetchOptions = {}) {
this.baseUrl = baseUrl;
this.defaultOptions = { ...DEFAULT_CONFIG, ...options };
this.cacheEnabled = true;
}
}
// Default configuration
const DEFAULT_CONFIG = {
timeout: 30000, // 30 seconds
retries: 3, // 3 retry attempts
retryDelay: 1000, // 1 second between retries
};
Built-In Caching
GET requests are automatically cached with LRU eviction:
// Cache configuration
const CACHE_SIZE = 100; // Max cached responses
const DEFAULT_TTL = 300000; // 5 minutes
// Cache is keyed by URL + body hash
const cacheKey = `${url}${options.body ? `_${JSON.stringify(options.body)}` : ''}`;
// Only GET requests without AbortSignal are cached
if (this.cacheEnabled && isGetRequest && !options.signal) {
const cached = cacheUtils.get(cacheKey);
if (cached) return { success: true, data: cached };
}
Retry Logic
// Retry decision: only network errors trigger retries
const shouldRetry =
attempt < retries &&
error instanceof Error &&
(error.name === 'TypeError' || error.message.includes('fetch'));
if (shouldRetry) {
await new Promise(resolve => setTimeout(resolve, retryDelay));
return attemptFetch(attempt + 1);
}
Timeout Handling
Every request wraps fetch with an AbortController:
const timeoutController = new AbortController();
const timeoutId = setTimeout(() => timeoutController.abort(), timeout);
// Composes with caller-provided signals
if (fetchOptions.signal) {
fetchOptions.signal.addEventListener('abort', () => {
timeoutController.abort(fetchOptions.signal?.reason);
}, { once: true });
}
Pre-Built Client Instances
// Default client for internal API calls
export const serverClient = new ServerClient();
// Factory for custom clients
export const createApiClient = (baseUrl: string, options?: FetchOptions) => {
return new ServerClient(baseUrl, options);
};
// External API client (longer timeout, fewer retries)
export const externalClient = new ServerClient('', {
timeout: 15000,
retries: 2,
});
// ReCAPTCHA verification client
export const recaptchaClient = {
async verify(token: string) {
return serverClient.post('/api/verify-recaptcha', { token });
},
};
Type System
Discriminated Union Responses
// lib/api/types.ts
export type ApiResponse<T = unknown> =
| { success: true; data: T; total?: number; page?: number; limit?: number }
| { success: false; error: string };
export type PaginatedResponse<T> =
| { success: true; data: T[]; meta: { page: number; totalPages: number; total: number; limit: number } }
| { success: false; error: string };
Type-Safe Response Handling
import { apiUtils } from '@/lib/api/server-api-client';
const response = await serverClient.get<Item[]>('/api/items');
if (apiUtils.isSuccess(response)) {
// TypeScript narrows: response.data is Item[]
console.log(response.data.length);
} else {
// TypeScript narrows: response.error is string
console.error(apiUtils.getErrorMessage(response));
}
React Query Integration
Default Query Configuration
// lib/api/constants.ts
export const QUERY_CONFIG = {
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 24 * 60 * 60 * 1000, // 24 hours
retry: 1,
refetchOnWindowFocus: false,
} as const;
Usage with React Query
import { useQuery } from '@tanstack/react-query';
import { fetcherGet, fetcherPaginated } from '@/lib/api/api-client';
import { QUERY_CONFIG } from '@/lib/api/constants';
function useItems(page: number) {
return useQuery({
queryKey: ['items', page],
queryFn: () => fetcherPaginated<Item>('/api/items', { page, limit: 20 }),
...QUERY_CONFIG,
});
}
API Utility Functions
import { apiUtils } from '@/lib/api/server-api-client';
// Build URL with query parameters
const url = apiUtils.buildUrl('/api/items', { page: 1, search: 'test' });
// Result: "/api/items?page=1&search=test"
// Create query string
const qs = apiUtils.createQueryString({ status: 'published', limit: 20 });
// Result: "status=published&limit=20"
// Clear all cached responses
apiUtils.clearCache();
File Upload
// Using ServerClient
const result = await serverClient.upload<{ url: string }>(
'/api/upload',
file,
{ timeout: 60000 } // Longer timeout for uploads
);
// Using form-encoded data
const result = await serverClient.postForm<{ token: string }>(
'/api/auth/token',
{ grant_type: 'client_credentials', client_id: 'xxx' }
);
Performance Considerations
- Singleton ensures one Axios instance: Avoids connection overhead from creating multiple clients.
- Server-side caching: Reduces redundant API calls. Disable for mutation-heavy workflows.
- React Query staleTime: 5 minutes prevents refetching on every component mount.
- gcTime of 24 hours: Keeps data in memory for fast navigation between pages.
- Retry count of 1: Balances resilience with user-facing latency.
Troubleshooting
Client-side requests fail with 401
- Check that the access token is set on the
ApiClientinstance. - Verify the token interceptor is attaching the
Authorizationheader. - Check that the token has not expired.
Server-side cache returns stale data
- Call
serverClient.clearCache()after mutations. - Set
setCacheEnabled(false)for endpoints that require fresh data. - Pass an
AbortSignalto bypass the cache for specific requests.
Timeout errors on external APIs
- Increase the timeout in the client configuration.
- Use the
externalClientwhich has a 15-second timeout. - Check network connectivity and DNS resolution.
React Query not refetching
- Verify the
queryKeyincludes all parameters that should trigger refetches. - Check that
staleTimeis not set too high for your use case. - Use
queryClient.invalidateQueriesafter mutations.