So you read my last post about Axios vs Fetch and you’re thinking „ok cool, but now what?“
Well…let me tell you something. Choosing your HTTP client was just the beginning.
The real productivity killer? It’s when your project grows and your API calls become a mess. Trust me, I’ve been there.
You know what happens next right? Your APIs start changing, types drift, errors pop up everywhere, and suddenly you’re spending more time debugging API calls than building features. Sound familiar?
Let’s fix this once and for all…
The Problem Nobody Talks About
First thing first. Your project starts small. A few API calls here and there, maybe some user auth, simple data fetching. Everything works fine with basic fetch or axios calls.
But then reality hits hard:
- APIs change without warning (classic backend team move)
- Your TypeScript interfaces don’t match what the API actually returns
- Error handling is all over the place
- New developers join and have no clue how your API layer works
- Runtime errors from type mismatches kill your confidence
Are you the type of developer who just keeps adding more try-catch blocks everywhere? Or do you want to solve this properly?
So lets do this…
Quick Reminder: Why Axios Still Wins for Productivity
Before we dive deep, let me remind you why this foundation matters:
Axios just makes your life easier. Period.
- Automatic JSON parsing (fetch makes you call .json() every time)
- Built-in error handling (no more checking response.ok manually)
- Interceptors for global logic (auth tokens, logging, whatever)
- Request cancellation that actually works
- Global config that applies everywhere
Yeah fetch is native and smaller, but unless you’re building a landing page, axios saves you time. And time is money, right?
Building Something That Actually Scales
Now here’s where it gets interesting. Most developers stop at „I’ll use axios“ and call it a day. But that’s like buying a Ferrari and only driving in first gear.
Let me show you how to build an API client that will make your future self thank you…
Step 1: Define Your Types (And Actually Use Them)
Look, TypeScript without proper interfaces is just JavaScript with extra steps. Define your API contract first:
interface User {
id: number;
name: string;
email: string;
avatar?: string;
}
interface ApiResponse<T> {
data: T;
message: string;
status: number;
}
interface ApiError {
message: string;
status: number;
code?: string;
}
Simple? Yes. Powerful? Absolutely.
Step 2: Create Your Base Client (The Smart Way)
Here’s where most people mess up. They create a new axios instance everywhere. Don’t do that. Build one client that handles everything:
import axios from 'axios';
class ApiClient {
private client;
constructor(baseURL, headers = {}) {
this.client = axios.create({
baseURL,
headers: {
'Content-Type': 'application/json',
...headers,
},
timeout: 10000,
});
this.setupInterceptors();
}
private setupInterceptors() {
// Add auth token automatically
this.client.interceptors.request.use((config) => {
const token = localStorage.getItem('authToken');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// Handle errors consistently
this.client.interceptors.response.use(
(response) => response,
(error) => {
const apiError = {
message: error.response?.data?.message || error.message,
status: error.response?.status || 500,
code: error.response?.data?.code,
};
return Promise.reject(apiError);
}
);
}
async get(url, config) {
try {
const response = await this.client.get(url, config);
return response.data;
} catch (error) {
throw error;
}
}
async post(url, data, config) {
try {
const response = await this.client.post(url, data, config);
return response.data;
} catch (error) {
throw error;
}
}
// add put, delete, etc...
}
See what I did there? One place for all your HTTP logic. Auth tokens? Handled. Error formatting? Handled. Timeouts? Handled.
Step 3: Organize by Services (Like a Pro)
Don’t dump all your API calls in one file. That’s amateur hour. Create services:
class UserService {
constructor(apiClient) {
this.api = apiClient;
}
async getUsers(page = 1, limit = 10) {
return await this.api.get(`/users?page=${page}&limit=${limit}`);
}
async getUserById(id) {
return await this.api.get(`/users/${id}`);
}
async createUser(userData) {
return await this.api.post('/users', userData);
}
async updateUser(id, userData) {
return await this.api.put(`/users/${id}`, userData);
}
async deleteUser(id) {
return await this.api.delete(`/users/${id}`);
}
}
Clean. Organized. Maintainable. Your team will love you.
Step 4: Put It All Together
Create a factory that gives you everything:
class ApiFactory {
constructor(baseURL, headers = {}) {
this.apiClient = new ApiClient(baseURL, headers);
this.userService = new UserService(this.apiClient);
}
get users() {
return this.userService;
}
// Add more services as you need them
}
// Usage in your app
const api = new ApiFactory(process.env.REACT_APP_API_BASE_URL);
export default api;
Now in your components:
const users = await api.users.getUsers();
const user = await api.users.getUserById(123);
Beautiful, right?
Pro Tips That Will Save Your Life
Runtime Validation (Because TypeScript Lies Sometimes)
TypeScript types disappear at runtime. Want real safety? Add runtime checks:
import { z } from 'zod';
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
});
// In your service
async getUserById(id) {
const response = await this.api.get(`/users/${id}`);
// This will throw if the API returns garbage
return UserSchema.parse(response.data);
}
Auto-Generate Everything (The Lazy Developer’s Dream)
Got an OpenAPI spec? Generate your entire client:
npm install @openapitools/openapi-generator-cli -g
openapi-generator-cli generate -i api-spec.yaml -g typescript-axios -o ./api-client
Boom. Types, methods, documentation. All generated. All type-safe. All maintained automatically.
Handle Loading States Like a Boss
class UserService {
private abortController = null;
async getUsers(page = 1) {
// Cancel previous request if user is clicking fast
if (this.abortController) {
this.abortController.abort();
}
this.abortController = new AbortController();
try {
return await this.api.get(`/users?page=${page}`, {
signal: this.abortController.signal
});
} finally {
this.abortController = null;
}
}
}
No more duplicate requests. No more race conditions. Clean UX.
When to Use What (The Real Talk)
Small project, just you coding?
Basic fetch with a thin wrapper is fine. Don’t over-engineer.
Medium project, small team?
This typed client approach is perfect. You’ll thank me later.
Large project, big team?
Generated clients all the way. Let the machines do the work.
Enterprise with changing APIs?
All of the above plus comprehensive testing and monitoring.
The Bottom Line
Look, choosing between axios and fetch was just step one. Building a maintainable, type-safe API layer? That’s where the real productivity gains happen.
This might seem like a lot of setup initially, but here’s what you get:
- Catch API problems at compile time
- Consistent error handling everywhere
- New team members understand your API instantly
- Refactoring becomes safe instead of scary
- Less debugging, more feature building
The tools are here. The patterns work. The question is: are you going to keep writing spaghetti API code or level up your game?
Your future self will thank you. Your team will thank you. Your users will thank you (because fewer bugs = better experience). I thank you xD
Just my thoughts for anyone dealing with API chaos. What is your approach? Let me know in the comments.
hm…I am thinking about diving into API caching strategies that can make your app 10x faster. Stay tuned