Multi-Tenant Axios API Client Implementation
This article provides a comprehensive guide to building a scalable, multi-tenant API client interface layer using the Axios HTTP library. It explores tenant identification strategies, dynamic request configuration through Axios interceptors, tenant context management, and the implementation of a modular repository-pattern interface to isolate domain logic from HTTP mechanics.
Understanding Multi-Tenant Client Architecture
Multi-tenancy requires an API client to adapt its requests dynamically based on the active tenant's context. Common tenant identification mechanisms include:
- Custom Headers: Passing a tenant identifier (e.g.,
X-Tenant-ID: tenant_123) in the HTTP request headers. - Subdomains / Base URLs: Routing requests to
tenant-specific domains (e.g.,
https://tenant1.api.example.com). - Path Prefixes: Prepending the tenant identifier to
request paths (e.g.,
/api/v1/tenants/tenant_123/resources).
The client interface layer decouples your application's business logic from these transport-level requirements, ensuring requests are automatically augmented with the correct tenant context.
1. Managing Tenant Context
To maintain separation of concerns, encapsulate the tenant state in a dedicated context provider or state store.
// tenantContext.ts
export interface TenantContext {
tenantId: string;
baseUrl?: string;
authToken?: string;
}
class TenantManager {
private currentTenant: TenantContext | null = null;
setTenant(tenant: TenantContext): void {
this.currentTenant = tenant;
}
getTenant(): TenantContext {
if (!this.currentTenant) {
throw new Error("No active tenant context found.");
}
return this.currentTenant;
}
clearTenant(): void {
this.currentTenant = null;
}
}
export const tenantManager = new TenantManager();2. Configuring the Core Axios Instance with Interceptors
Use Axios interceptors to dynamically inject tenant-specific headers,
authorization tokens, or rewrite the baseURL for every
outgoing request.
// httpClient.ts
import axios, { AxiosInstance, InternalAxiosRequestConfig } from 'axios';
import { tenantManager } from './tenantContext';
export const createHttpClient = (defaultBaseUrl: string): AxiosInstance => {
const instance = axios.create({
baseURL: defaultBaseUrl,
timeout: 10000,
headers: {
'Content-Type': 'application/json',
},
});
// Request Interceptor: Attach Tenant Identification and Auth
instance.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
try {
const tenant = tenantManager.getTenant();
// 1. Dynamic Header Injection
config.headers.set('X-Tenant-ID', tenant.tenantId);
// 2. Bearer Authentication Injection
if (tenant.authToken) {
config.headers.set('Authorization', `Bearer ${tenant.authToken}`);
}
// 3. Dynamic Base URL Override (if subdomains are used)
if (tenant.baseUrl) {
config.baseURL = tenant.baseUrl;
}
} catch (error) {
// Fallback or bypass for unauthenticated/public endpoints
}
return config;
},
(error) => Promise.reject(error)
);
// Response Interceptor: Handle Tenant-Specific Errors
instance.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 403 && error.response?.data?.code === 'TENANT_SUSPENDED') {
// Handle tenant-level authorization or lifecycle events
}
return Promise.reject(error);
}
);
return instance;
};3. Creating the API Client Interface Layer
Structure the interface layer using the Repository pattern. This provides strongly typed endpoints while abstracting the underlying Axios instance.
// api/types.ts
export interface User {
id: string;
name: string;
email: string;
}
export interface ApiClientInterface {
users: {
getAll: () => Promise<User[]>;
getById: (id: string) => Promise<User>;
create: (payload: Partial<User>) => Promise<User>;
};
}// api/client.ts
import { AxiosInstance } from 'axios';
import { ApiClientInterface, User } from './types';
export class ApiClient implements ApiClientInterface {
constructor(private http: AxiosInstance) {}
public users = {
getAll: async (): Promise<User[]> => {
const response = await this.http.get<User[]>('/users');
return response.data;
},
getById: async (id: string): Promise<User> => {
const response = await this.http.get<User>(`/users/${id}`);
return response.data;
},
create: async (payload: Partial<User>): Promise<User> => {
const response = await this.http.post<User>('/users', payload);
return response.data;
},
};
}4. Initializing and Consuming the Client
Initialize the client with your default configuration and access your tenant-scoped endpoints throughout the application.
// index.ts
import { createHttpClient } from './httpClient';
import { ApiClient } from './api/client';
import { tenantManager } from './tenantContext';
// 1. Initialize HTTP client and API Client Layer
const httpClient = createHttpClient('https://api.example.com/v1');
const api = new ApiClient(httpClient);
// 2. Set Tenant Context during authentication or tenant switching
tenantManager.setTenant({
tenantId: 'tenant-alpha',
authToken: 'jwt-token-xyz',
baseUrl: 'https://tenant-alpha.api.example.com/v1',
});
// 3. Execute requests seamlessly with tenant context applied
async function loadTenantData() {
try {
const users = await api.users.getAll();
console.log('Loaded users:', users);
} catch (error) {
console.error('Failed to load tenant users:', error);
}
}
loadTenantData();Best Practices for Multi-Tenant Clients
- Avoid Shared Mutable Cache: When implementing
client-side caching (e.g., with React Query or SWR), ensure cache keys
include the
tenantIdto prevent data leakage between tenants. - Tenant Switching Cleanup: Clear any stateful request buffers, headers, and in-memory caches when changing the active tenant.
- Centralized Error Normalization: Standardize
tenant-related API errors (e.g.,
TenantNotFound,TenantQuotaExceeded) within response interceptors to simplify error handling in UI components.