TL;DR: Learn how to create a centralized, robust, and reusable Axios client in TypeScript that handles encryption, error categorization, logging, and consistent response handling — all while improving maintainability across your app.
If you're building a medium-to-large-scale web application using TypeScript (especially with React or Node.js), you've probably run into this situation:
“Wait… why is each API call doing its own error handling and decrypting logic?!”
That’s where a centralized Axios instance becomes a game-changer. 🔍 In this post, I'll walk through how we implemented a clean, scalable AxiosClient
class in our project — and why it's the secret sauce behind robust API communication.
🧱 The Problem: Disorganized API Layer
Before implementing our AxiosClient
, every API file had its own way of:
- Handling errors ❌
- Decrypting payloads 🔐
- Logging responses 📜
- Managing timeouts or retry logic ⏳
This led to:
- Code duplication 🐛
- Inconsistent error messages 💣
- Hard-to-debug flows 🤯
So we decided to unify everything under one roof — enter the AxiosClient
. 🎯
🌟 What Is AxiosClient?
The AxiosClient
is a singleton Axios instance configured with interceptors for response/error handling. It ensures:
- One source of truth for all API calls 🔄
- Consistent structure in success/failure cases ✅
- Encrypted payload support 🔐
- Debug-friendly logs 📦
- Clear error categorization 🚨
Here’s what it does under the hood:
✅ Singleton Pattern
Only one instance is created and reused throughout the app. No more redundant config setups!
private static instance: AxiosInstance;
public static getInstance(): AxiosInstance {
if (!AxiosClient.instance) {
AxiosClient.instance = axios.create({
baseURL: BACKEND_BASE_URL,
timeout: 10000,
headers: { "Content-Type": "application/json" },
});
AxiosClient.instance.interceptors.response.use(
(response) => AxiosClient.handleApiResponse(response),
(error) => AxiosClient.handleApiError(error)
);
}
return AxiosClient.instance;
}
🔁 Response Interception
Every successful response goes through handleApiResponse()
. This method:
- Checks for valid data structure 🧪
- Handles encrypted payloads via
CryptoHelper.decryptPayload
🔐 - Ensures
success: true
before returning actual data ✅ - Throws
AppError
when something goes wrong ❌
private static async handleApiResponse<T>(
response: AxiosResponse<ResponseModel<T> | EncryptedMessage>
): Promise<AxiosResponse<T>> {
...
}
⚠️ Error Interception
All errors are caught by handleApiError()
, which categorizes them:
- API Errors: Based on HTTP status codes (e.g., 401 Unauthorized) 🚫
- Network Errors: When no response is received 🌐
- Unknown Errors: Everything else 🤷♂️
Each error is wrapped in an AppError
class with metadata like message, code, and original response.
private static handleApiError(error: AxiosError | AppError): never {
...
}
🔐 Encrypted Payloads Support
We use AES-GCM or similar algorithms to encrypt sensitive payloads on the backend. Our client automatically detects and decrypts these payloads using CryptoHelper
.
if (typeof data.payload === "object" && "iv" in data.payload && "data" in data.payload) {
data.payload = await CryptoHelper.decryptPayload(data.payload);
}
If decryption fails, it throws a clear AppError
.
📦 How to Use It in Your APIs
Once you have the AxiosClient
, using it is simple:
import AxiosClient from "../classes/AxiosClient";
const apiClient = AxiosClient.getInstance();
export async function fetchUserData() {
const response = await apiClient.get("/user/profile");
return response.data; // Already decrypted and validated!
}
No need to write error handling or decryption logic here — it's already taken care of. 🧼
🎯 Benefits of Using AxiosClient
Benefit | Description |
---|---|
Centralized Configuration 🧩 | All API settings live in one place |
Consistent Behavior 🔄 | Every API call follows the same flow |
Improved Debugging 🔍 | Logs help track down issues faster |
Scalable Architecture 🚀 | Easy to extend with new interceptors |
Security Ready 🔐 | Built-in support for encrypted payloads |
Maintainable Codebase 🛠️ | Changes propagate everywhere instantly |
TL;DR Summary
Create a centralized Axios client to:
- Reduce boilerplate 🧹
- Handle common tasks (errors, logging, decryption) 🧰
- Improve consistency 🔄
- Boost developer productivity 💡
With just one class (AxiosClient
), you get a powerful abstraction layer over your entire API surface. 🧠
📝 Missing Implementations You’ll Need
To make this work, here are the key pieces you’ll want to implement yourself:
1. CryptoHelper.ts
A helper class for decrypting payloads. You can use libraries like crypto-js
or Web Crypto API.
export class CryptoHelper {
static async decryptPayload(payload: EncryptedMessage): Promise<any> {
// your AES-GCM or equivalent decryption logic here 🔐
}
}
2. AppError.ts
A custom error class that wraps extra context like error code, raw data, etc.
export class AppError extends Error {
constructor(
public message: string,
public code?: string,
public data?: any
) {
super(message);
this.name = "AppError";
}
}
3. constants.ts
Error messages and base URL constants.
export const AXIOS_CLIENT_ERROR_MESSAGES = {
MISSING_RESPONSE_DATA: "Response data is missing",
INVALID_RESPONSE_FORMAT: "Invalid response format",
DECRYPTION_FAILED: "Failed to decrypt payload",
FAILED_AT_API_LEVEL: "Request failed at API level",
NO_RESPONSE_FROM_SERVER: "No response from server",
UNSPECIFIED_ERROR: "An unspecified error occurred",
MISSING_PAYLOAD: "Missing payload in response",
INVALID_ENCRYPTED_MESSAGE: "Invalid encrypted message"
};
export const BACKEND_BASE_URL = process.env.BACKEND_BASE_URL || "https://api.yourapp.com";
📦 Interfaces You Might Need
ResponseModel<T>
export interface ResponseModel<T> {
success: boolean;
payload: T;
}
EncryptedMessage
export interface EncryptedMessage {
iv: string;
data: string;
}
👨💻 Final Thoughts
Using a centralized Axios client isn’t just about reducing lines of code — it's about shaping your application architecture around reusability, readability, and robustness. 🧠
Whether you're working solo or in a team, investing time upfront to set up a solid Axios foundation will pay dividends later. 💸
Happy coding! 🚀
full code:
import axios, { AxiosError, AxiosInstance, AxiosResponse } from "axios";
import { AXIOS_CLIENT_ERROR_MESSAGES, BACKEND_BASE_URL } from "../constants";
import { CryptoHelper } from "../helpers/cryptoHelper";
import { AppError } from "./AppError";
import { EncryptedMessage, ResponseModel } from "../interfaces/transmitModel";
/**
* 🚀 Centralized Axios instance that wraps all API calls.
*
* Features:
* - Singleton pattern to reuse one instance
* - Interceptors for consistent response/error handling
* - Automatic decryption of encrypted payloads
* - Unified error categorization (API, network, unknown)
* - Custom logging (optional or replaceable)
*/
export default class AxiosClient {
private static instance: AxiosInstance;
/**
* 🔄 Returns a singleton instance of Axios.
* Only creates once and reuses in subsequent calls.
*/
public static getInstance(): AxiosInstance {
if (!AxiosClient.instance) {
// Create a new Axios instance with base config
AxiosClient.instance = axios.create({
baseURL: BACKEND_BASE_URL,
timeout: 10000, // 10 seconds timeout
headers: {
"Content-Type": "application/json",
},
});
// Setup interceptors for responses
AxiosClient.instance.interceptors.response.use(
(response) => AxiosClient.handleApiResponse(response),
(error) => AxiosClient.handleApiError(error)
);
}
return AxiosClient.instance;
}
/**
* 📤 Handles successful responses from the server.
* - Validates structure
* - Decrypts payloads if needed
* - Throws AppError on failure
*/
private static async handleApiResponse<T>(
response: AxiosResponse<ResponseModel<T> | EncryptedMessage>
): Promise<AxiosResponse<T>> {
try {
// Log raw response for debugging (can be replaced with real logger)
console.log("📡 API Response Received:", response);
// Ensure we have data in the response
if (!response.data) {
throw new AppError(AXIOS_CLIENT_ERROR_MESSAGES.MISSING_RESPONSE_DATA);
}
const responseData = response.data;
// Check if it's a standard success/failure response
if ("success" in responseData && "payload" in responseData) {
// Standard API response
const data = responseData as ResponseModel<T>;
if (!data.success) {
// Handle known API-level errors
let errorData: any = AXIOS_CLIENT_ERROR_MESSAGES.UNKNOWN_ERROR;
if (data.payload && typeof data.payload === "object" && "iv" in data.payload && "data" in data.payload) {
// If payload is encrypted, attempt to decrypt
errorData = await CryptoHelper.decryptPayload(data.payload).catch(() =>
AXIOS_CLIENT_ERROR_MESSAGES.DECRYPTION_FAILED
);
} else if (data.payload) {
errorData = AXIOS_CLIENT_ERROR_MESSAGES.INVALID_ENCRYPTED_MESSAGE;
}
throw new AppError(AXIOS_CLIENT_ERROR_MESSAGES.FAILED_AT_API_LEVEL, undefined, errorData);
}
if (!data.payload) {
throw new AppError(AXIOS_CLIENT_ERROR_MESSAGES.MISSING_PAYLOAD);
}
// If payload is encrypted, decrypt it
if (
typeof data.payload === "object" &&
"iv" in data.payload &&
"data" in data.payload
) {
data.payload = (await CryptoHelper.decryptPayload(data.payload).catch(() => {
throw new AppError(AXIOS_CLIENT_ERROR_MESSAGES.DECRYPTION_FAILED);
})) as T;
}
// Return modified response with decrypted payload
return { ...response, data: data.payload } as AxiosResponse<T>;
}
// If not a standard format, assume it's a direct encrypted message
if ("iv" in responseData && "data" in responseData) {
const decrypted = await CryptoHelper.decryptPayload(responseData as EncryptedMessage);
return { ...response, data: decrypted } as AxiosResponse<T>;
}
// Unknown response format
throw new AppError(AXIOS_CLIENT_ERROR_MESSAGES.INVALID_RESPONSE_FORMAT);
} catch (err) {
// Wrap generic errors into AppError
throw err instanceof AppError ? err : new AppError(AXIOS_CLIENT_ERROR_MESSAGES.UNSPECIFIED_ERROR, undefined, err);
}
}
/**
* ⚠️ Handles Axios and general errors.
* Categorizes them into:
* - API Errors (4xx, 5xx)
* - Network Errors
* - Unknown Errors
*/
private static handleApiError(error: AxiosError | AppError): never {
if (error instanceof AppError) {
// Already handled by us ✅
throw error;
}
if (axios.isAxiosError(error)) {
// Server responded with status code
if (error.response) {
const { status, data: responseData } = error.response;
// Map common HTTP status codes to readable messages
const defaultMessage =
{
400: "Bad Request",
401: "Unauthorized",
403: "Forbidden",
404: "Not Found",
409: "Conflict",
429: "Too Many Requests",
500: "Internal Server Error",
503: "Service Unavailable",
}[status] ||
(responseData as { message?: string })?.message ||
AXIOS_CLIENT_ERROR_MESSAGES.FAILED_AT_API_LEVEL;
throw new AppError(defaultMessage, `${status}`, responseData);
}
// No response received (network issue)
if (error.request) {
throw new AppError(AXIOS_CLIENT_ERROR_MESSAGES.NO_RESPONSE_FROM_SERVER);
}
// Something else went wrong during request setup
throw new AppError(AXIOS_CLIENT_ERROR_MESSAGES.UNSPECIFIED_ERROR, undefined, error.message);
}
// Unknown error type
console.error("🚨 Unknown error:", error);
throw new AppError(AXIOS_CLIENT_ERROR_MESSAGES.UNSPECIFIED_ERROR);
}
}