DEV Community

Cover image for Stop the `data.data` Madness: Building a Custom Axios Client in TypeScript

Stop the `data.data` Madness: Building a Custom Axios Client in TypeScript

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 }
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

If I had to type response.data.data one 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}`);

Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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>;
  },
};
Enter fullscreen mode Exit fullscreen mode

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>;
  },
Enter fullscreen mode Exit fullscreen mode

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; 
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. You stop typing .data.data.
  2. You get perfect TypeScript autocomplete.
  3. You separate your HTTP logic from your UI logic.

Happy coding! 🚀

Top comments (0)