I was recently working on a project where the backend team decided (wisely) to wrap every API response in a standard envelope:
{
"message": "Success",
"data": { "id": 1, "name": "John Doe" },
"meta": { "page": 1, "total": 100 }
}
On the backend, this is clean and consistent. I actually love it.
But in React components, it turns the code into a repetitive nightmare.
Just look at this:
// 🤮 The Issue
const response = await axios.get('/users/1');
// Double data? Really?
const user = response.data.data;
const message = response.data.message;
If I had to type
response.data.dataone more time, I was going to scream.
If you are a frontend developer working with a structured backend, you know this struggle.
Today, let’s fix this. We are going to build a Custom API Client in TypeScript that unwraps this envelope automatically, handles types safely, and keeps your component logic clean.
The Goal
We want to transform Axios so that when we call get, we receive the inner data immediately at the top level, fully typed.
Target Usage:
// 😍 The New Way
const { data, message, meta } = await apiClient.get<User>('/users/1');
// TypeScript knows 'data' is a User!
console.log(data.name);
// ✅ Access the server message:
console.log(`Alert: ${response.message}`);
// ✅ Meta data {page: 1, totalPages: 6, totalProducts: 120}
console.log(`Current page: ${response.meta?.page}`);
Step 1: Define the Backend Contract
First, we need to tell TypeScript what the "Raw" response looks like coming from the server.
// The shape of the raw JSON from the server
export interface ApiResponse<T = undefined> {
message: string;
data: T | null;
}
// The shape when pagination metadata is involved
export interface ApiResponseWithMeta<T = undefined, M = undefined> {
message: string;
data: T | [];
meta?: M;
}
Step 2: The "Typed" Response
Next, we define what we want our frontend to receive. We are going to "hoist" the message and meta fields up, so they sit right next to our actual data, removing that annoying nesting.
import { AxiosResponse } from 'axios';
// We omit the standard 'data' property and replace it with our own typed version
export interface TypedAxiosResponse<T = undefined> extends Omit<AxiosResponse, 'data'> {
data: T | null; // The actual resource (User, Product, etc.)
message: string; // Hoisted from the nested object
}
// A version for paginated responses
export interface TypedAxiosResponseWithMeta<T = undefined, M = undefined> extends TypedAxiosResponse<T> {
meta?: M;
}
Step 3: The Magic Wrapper
Instead of using the raw axios instance directly in our components, we create an apiClient object. This acts as a Facade. It calls Axios, intercepts the result, and unwraps it before returning it to you.
Here is the logic:
import axios, { AxiosRequestConfig } from 'axios';
// 1. Create the base instance with your interceptors (Auth, Logging, etc.)
const baseApiClient = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_URL,
});
// 2. The Custom Client
export const apiClient = {
get: async <T = undefined>(
url: string,
config?: AxiosRequestConfig
): Promise<TypedAxiosResponse<T>> => {
// Call the raw axios instance
const response = await baseApiClient.get<ApiResponse<T>>(url, config);
// ✨ THE MAGIC: Remap response.data.data -> response.data
return {
...response,
data: response.data.data, // Unwrapping happens here
message: response.data.message,
} as TypedAxiosResponse<T>;
},
// You can repeat this pattern for post, put, delete...
post: async <T = undefined>(
url: string,
data?: unknown,
config?: AxiosRequestConfig
): Promise<TypedAxiosResponse<T>> => {
const response = await baseApiClient.post<ApiResponse<T>>(url, data, config);
return {
...response,
data: response.data.data,
message: response.data.message,
} as TypedAxiosResponse<T>;
},
};
Step 4: Handling Pagination (The meta Field)
Sometimes you need that extra metadata (page numbers, total counts). Let's add a specific method for that so we don't clutter standard calls.
getWithMeta: async <T = undefined, M = undefined>(
url: string,
config?: AxiosRequestConfig
): Promise<TypedAxiosResponseWithMeta<T, M>> => {
const response = await baseApiClient.get<ApiResponseWithMeta<T, M>>(url, config);
return {
...response,
data: response.data.data,
message: response.data.message,
meta: response.data.meta, // Now easily accessible!
} as TypedAxiosResponseWithMeta<T, M>;
},
The Result
Now, look at how clean your service files or components become.
Before:
const getUser = async (id: string) => {
const res = await axios.get(`/users/${id}`);
// Hope you remember to check if res.data.data exists!
return res.data.data;
}
After:
interface User { id: number; name: string; }
const getUser = async (id: string) => {
// Typescript infers 'data' is User, and 'message' is string
const { data, message } = await apiClient.get<User>(`/users/${id}`);
toast.success(message); // "User retrieved successfully"
return data;
}
Bonus: Centralized Error Handling
Because we still use baseApiClient underneath, we can attach interceptors to handle 401s (Unauthorized) or 500s globally.
If your token expires, your base client can catch it, attempt a refresh, or redirect to login via next-auth before your component even knows something went wrong.
Summary
Don't let your backend structure dictate your frontend messiness. By creating a lightweight wrapper around Axios:
- You stop typing
.data.data. - You get perfect TypeScript autocomplete.
- You separate your HTTP logic from your UI logic.
Happy coding! 🚀
Top comments (0)