Skip to main content

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

  1. Singleton ensures one Axios instance: Avoids connection overhead from creating multiple clients.
  2. Server-side caching: Reduces redundant API calls. Disable for mutation-heavy workflows.
  3. React Query staleTime: 5 minutes prevents refetching on every component mount.
  4. gcTime of 24 hours: Keeps data in memory for fast navigation between pages.
  5. Retry count of 1: Balances resilience with user-facing latency.

Troubleshooting

Client-side requests fail with 401

  1. Check that the access token is set on the ApiClient instance.
  2. Verify the token interceptor is attaching the Authorization header.
  3. Check that the token has not expired.

Server-side cache returns stale data

  1. Call serverClient.clearCache() after mutations.
  2. Set setCacheEnabled(false) for endpoints that require fresh data.
  3. Pass an AbortSignal to bypass the cache for specific requests.

Timeout errors on external APIs

  1. Increase the timeout in the client configuration.
  2. Use the externalClient which has a 15-second timeout.
  3. Check network connectivity and DNS resolution.

React Query not refetching

  1. Verify the queryKey includes all parameters that should trigger refetches.
  2. Check that staleTime is not set too high for your use case.
  3. Use queryClient.invalidateQueries after mutations.