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:

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