Setup
Axios Instance Configuration
Create a configured axios instance with base URL, timeouts, and default headers. This ensures consistency across all API calls.
# Install axios
npm install axios
// lib/axios.ts
import axios from "axios";
// Create axios instance with base configuration
const apiClient = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_URL || "http://localhost:3000/api",
timeout: 10000, // 10 seconds
headers: {
"Content-Type": "application/json",
},
});
export default apiClient;
💡 Key Point: Use environment variables for API URLs. This makes it easy to switch between development, staging, and production environments.
Interceptors
Request & Response Interceptors
Interceptors let you transform requests/responses globally. Add auth tokens, log requests, handle errors, refresh tokens automatically.
// lib/axios.ts (continued)
import axios from "axios";
import { getToken, refreshToken, logout } from "./auth";
const apiClient = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_URL || "http://localhost:3000/api",
timeout: 10000,
});
// ─────────────────────────────────────────────────────────────
// REQUEST INTERCEPTOR - Runs before every request
// ─────────────────────────────────────────────────────────────
apiClient.interceptors.request.use(
(config) => {
// 1. Add auth token to every request
const token = getToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
// 2. Add request ID for tracking
config.headers["X-Request-ID"] = crypto.randomUUID();
// 3. Log request in development
if (process.env.NODE_ENV === "development") {
console.log(`[${config.method?.toUpperCase()}] ${config.url}`, config.data);
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// ─────────────────────────────────────────────────────────────
// RESPONSE INTERCEPTOR - Runs after every response
// ─────────────────────────────────────────────────────────────
apiClient.interceptors.response.use(
(response) => {
// Success - just return the response
return response;
},
async (error) => {
const originalRequest = error.config;
// 1. Handle 401 Unauthorized - Token expired
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
try {
// Try to refresh the token
const newToken = await refreshToken();
// Update the failed request with new token
originalRequest.headers.Authorization = `Bearer ${newToken}`;
// Retry the original request
return apiClient(originalRequest);
} catch (refreshError) {
// Refresh failed - log user out
logout();
window.location.href = "/login";
return Promise.reject(refreshError);
}
}
// 2. Handle 403 Forbidden
if (error.response?.status === 403) {
console.error("Access denied");
// Show toast notification
}
// 3. Handle 500 Server Error
if (error.response?.status >= 500) {
console.error("Server error:", error.response.data);
// Show error notification
}
// 4. Handle Network Error (no internet)
if (!error.response) {
console.error("Network error - check your connection");
}
return Promise.reject(error);
}
);
export default apiClient;
💡 Key Point: Request interceptors run BEFORE sending. Response interceptors run AFTER receiving. Use them for cross-cutting concerns like auth, logging, and error handling.
Services
API Service Layer (Read & Write)
Create a service layer to organize all API calls. This keeps your components clean and makes testing easier.
// services/userService.ts
import apiClient from "@/lib/axios";
export interface User {
id: string;
name: string;
email: string;
role: string;
}
export interface CreateUserData {
name: string;
email: string;
password: string;
}
export interface UpdateUserData {
name?: string;
email?: string;
}
// ─────────────────────────────────────────────────────────────
// READ OPERATIONS
// ─────────────────────────────────────────────────────────────
export const userService = {
// Get all users
getAll: async (params?: { page?: number; limit?: number }) => {
const response = await apiClient.get<User[]>("/users", { params });
return response.data;
},
// Get single user by ID
getById: async (id: string) => {
const response = await apiClient.get<User>(`/users/${id}`);
return response.data;
},
// Get current user profile
getProfile: async () => {
const response = await apiClient.get<User>("/users/me");
return response.data;
},
// Search users
search: async (query: string) => {
const response = await apiClient.get<User[]>("/users/search", {
params: { q: query },
});
return response.data;
},
// ─────────────────────────────────────────────────────────────
// WRITE OPERATIONS
// ─────────────────────────────────────────────────────────────
// Create new user
create: async (data: CreateUserData) => {
const response = await apiClient.post<User>("/users", data);
return response.data;
},
// Update user
update: async (id: string, data: UpdateUserData) => {
const response = await apiClient.patch<User>(`/users/${id}`, data);
return response.data;
},
// Delete user
delete: async (id: string) => {
await apiClient.delete(`/users/${id}`);
},
// Upload user avatar
uploadAvatar: async (id: string, file: File) => {
const formData = new FormData();
formData.append("avatar", file);
const response = await apiClient.post<{ url: string }>(
`/users/${id}/avatar`,
formData,
{
headers: {
"Content-Type": "multipart/form-data",
},
}
);
return response.data;
},
// Batch operations
bulkDelete: async (ids: string[]) => {
await apiClient.post("/users/bulk-delete", { ids });
},
bulkUpdate: async (updates: Array<{ id: string; data: UpdateUserData }>) => {
const response = await apiClient.post<User[]>("/users/bulk-update", {
updates,
});
return response.data;
},
};
Usage in Components:
"use client";
import { useState, useEffect } from "react";
import { userService, User } from "@/services/userService";
export default function UsersPage() {
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadUsers();
}, []);
const loadUsers = async () => {
try {
const data = await userService.getAll({ page: 1, limit: 10 });
setUsers(data);
} catch (error) {
console.error("Failed to load users:", error);
} finally {
setLoading(false);
}
};
const handleDelete = async (id: string) => {
try {
await userService.delete(id);
setUsers(users.filter((u) => u.id !== id));
} catch (error) {
console.error("Failed to delete user:", error);
}
};
if (loading) return <div>Loading...</div>;
return (
<div>
{users.map((user) => (
<div key={user.id}>
{user.name} - {user.email}
<button onClick={() => handleDelete(user.id)}>Delete</button>
</div>
))}
</div>
);
}
💡 Key Point: Service layer separates business logic from UI. It's easier to test, reuse, and maintain. All API calls go through services.
Hooks
Custom Hooks for Data Fetching
Encapsulate data fetching logic in custom hooks. Handle loading, error states, and refetching automatically.
// hooks/useApi.ts
import { useState, useEffect } from "react";
interface UseApiState<T> {
data: T | null;
loading: boolean;
error: Error | null;
}
interface UseApiReturn<T> extends UseApiState<T> {
refetch: () => Promise<void>;
}
// Generic hook for any API call
export function useApi<T>(
apiFunction: () => Promise<T>,
dependencies: any[] = []
): UseApiReturn<T> {
const [state, setState] = useState<UseApiState<T>>({
data: null,
loading: true,
error: null,
});
const fetchData = async () => {
setState((prev) => ({ ...prev, loading: true, error: null }));
try {
const data = await apiFunction();
setState({ data, loading: false, error: null });
} catch (error) {
setState({
data: null,
loading: false,
error: error as Error,
});
}
};
useEffect(() => {
fetchData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, dependencies);
return { ...state, refetch: fetchData };
}
// Specific hooks for different resources
export function useUsers() {
return useApi(() => userService.getAll());
}
export function useUser(id: string) {
return useApi(() => userService.getById(id), [id]);
}
export function useUserSearch(query: string) {
return useApi(() => userService.search(query), [query]);
}
Usage with Custom Hooks:
"use client";
import { useUsers } from "@/hooks/useApi";
export default function UsersPage() {
const { data: users, loading, error, refetch } = useUsers();
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<div>
<button onClick={refetch}>Refresh</button>
{users?.map((user) => (
<div key={user.id}>
{user.name} - {user.email}
</div>
))}
</div>
);
}
💡 Key Point: Custom hooks eliminate boilerplate. Components don't need to manage loading/error states manually. Consider using SWR or React Query for production.
HOC Pattern
Higher-Order Component for API Protection
Use HOCs to wrap components that need data fetching, authentication, or authorization. Keeps your components clean and reusable.
// hoc/withAuth.tsx
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { getToken } from "@/lib/auth";
export function withAuth<P extends object>(
Component: React.ComponentType<P>
) {
return function AuthenticatedComponent(props: P) {
const router = useRouter();
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [loading, setLoading] = useState(true);
useEffect(() => {
const token = getToken();
if (!token) {
router.push("/login");
} else {
setIsAuthenticated(true);
}
setLoading(false);
}, [router]);
if (loading) {
return <div>Loading...</div>;
}
if (!isAuthenticated) {
return null;
}
return <Component {...props} />;
};
}
// Usage
function DashboardPage() {
return <div>Protected Dashboard</div>;
}
export default withAuth(DashboardPage);
HOC for Data Fetching:
// hoc/withData.tsx
import { useState, useEffect } from "react";
interface WithDataProps<T> {
data: T | null;
loading: boolean;
error: Error | null;
refetch: () => void;
}
export function withData<T, P extends object>(
Component: React.ComponentType<P & WithDataProps<T>>,
fetcher: () => Promise<T>
) {
return function DataComponent(props: P) {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const fetchData = async () => {
setLoading(true);
try {
const result = await fetcher();
setData(result);
setError(null);
} catch (err) {
setError(err as Error);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchData();
}, []);
return (
<Component
{...props}
data={data}
loading={loading}
error={error}
refetch={fetchData}
/>
);
};
}
// Usage
interface UserListProps {
data: User[] | null;
loading: boolean;
error: Error | null;
}
function UserList({ data, loading, error }: UserListProps) {
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<div>
{data?.map((user) => (
<div key={user.id}>{user.name}</div>
))}
</div>
);
}
export default withData(UserList, userService.getAll);
💡 Key Point: HOCs are great for cross-cutting concerns like auth, logging, or data fetching. In modern React, consider using hooks instead for better TypeScript support.
Error Handling
Centralized Error Handling
Create a centralized error handler to process API errors consistently across your app. Show user-friendly messages and log errors.
// lib/errorHandler.ts
import toast from "react-hot-toast";
export interface ApiError {
message: string;
code?: string;
statusCode?: number;
details?: any;
}
export function handleApiError(error: any): ApiError {
// Axios error with response
if (error.response) {
const { status, data } = error.response;
switch (status) {
case 400:
toast.error(data.message || "Invalid request");
break;
case 401:
toast.error("Unauthorized - please login");
break;
case 403:
toast.error("Access denied");
break;
case 404:
toast.error("Resource not found");
break;
case 409:
toast.error(data.message || "Conflict");
break;
case 422:
toast.error("Validation error");
break;
case 429:
toast.error("Too many requests - please slow down");
break;
case 500:
toast.error("Server error - please try again later");
break;
default:
toast.error(data.message || "Something went wrong");
}
return {
message: data.message || "Request failed",
code: data.code,
statusCode: status,
details: data.details,
};
}
// Network error
if (error.request) {
toast.error("Network error - check your connection");
return {
message: "Network error",
code: "NETWORK_ERROR",
};
}
// Other errors
toast.error(error.message || "An error occurred");
return {
message: error.message || "Unknown error",
};
}
// Use in services
export const safeApiCall = async <T,>(
apiFunction: () => Promise<T>
): Promise<T | null> => {
try {
return await apiFunction();
} catch (error) {
handleApiError(error);
return null;
}
};
Usage:
import { safeApiCall } from "@/lib/errorHandler";
async function loadUsers() {
const users = await safeApiCall(() => userService.getAll());
if (users) {
setUsers(users);
}
// Error already handled and toast shown
}