API Client
A centralized HTTP client with auth injection, retry logic, error normalization, and production-safe error messages.
What Problem This Solves
You need an HTTP client that:
- Automatically attaches Bearer tokens from your auth store
- Retries GET requests on transient failures (408, 429, 5xx)
- Normalizes errors with dev/prod-safe messaging
- Handles 401 unauthorized by clearing auth and redirecting
- Supports cookie mode (no Authorization header,
credentials: 'include')
Prerequisites
ky(lightweight fetch wrapper)- Zustand auth store (or any store with
getState().token)
Files
fetcher.ts
ts
import ky, { HTTPError, type Options } from "ky";
import { STORAGE_MODE, useAuth } from "@/lib/stores/auth";
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
const service = ky.create({
prefixUrl: API_BASE_URL,
timeout: 60_000,
credentials: STORAGE_MODE === "cookie" ? "include" : undefined,
retry: {
limit: 2,
methods: ["get"],
statusCodes: [408, 429, 500, 502, 503, 504],
backoffLimit: 3000,
},
hooks: {
beforeRequest: [
({ request }) => {
if (STORAGE_MODE === "cookie") return;
const token = useAuth.getState().token;
if (token) {
request.headers.set("Authorization", `Bearer ${token}`);
}
},
],
afterResponse: [
({ response }) => {
if (response.status === 401) {
useAuth.getState().clearAuth();
window.location.href = "/login";
}
},
],
},
});
export type ApiSuccess<T> = {
data: T;
message?: string;
};
export type ApiError = {
message: string;
status?: number;
code?: string;
};
export type APIFetcherParams = {
rootPath?: string;
path: string;
config?: Options;
};
const normalizeApiError = async (error: unknown): Promise<ApiError> => {
if (error instanceof HTTPError) {
const body = await error.response.json().catch(() => null);
return {
message: import.meta.env.DEV
? (body?.message ?? error.message)
: "An error occurred. Please try again.",
status: error.response.status,
code: body?.code,
};
}
if (error instanceof Error) {
return {
message: import.meta.env.DEV ? error.message : "An error occurred. Please try again.",
};
}
return { message: "An unexpected error occurred" };
};
export const fetcher = async <ResponseDataType>({
rootPath = "",
path,
config,
}: APIFetcherParams): Promise<ResponseDataType> => {
const response = await service<ResponseDataType>(
`${rootPath}${path}`.replace(/^\/+/, ""),
config,
);
return response.json();
};
export const fetcherOriginResp = async <ResponseDataType>({
rootPath = "",
path,
config,
}: APIFetcherParams): Promise<Response> => {
return service<ResponseDataType>(`${rootPath}${path}`.replace(/^\/+/, ""), config);
};constants.ts
ts
export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "/";Usage
Basic GET
ts
import { fetcher } from "@/lib/services/api/fetcher";
const users = await fetcher<User[]>({ path: "users" });With config (POST, query params, etc.)
ts
const user = await fetcher<User>({
path: "users",
config: {
method: "POST",
json: { name: "John" },
},
});Downloading files
ts
const response = await fetcherOriginResp<Blob>({
path: "reports/export",
config: { method: "GET" },
});
const blob = await response.blob();
const filename = response.headers.get("content-disposition")?.match(/filename="(.+)"/)?.[1];Error handling
ts
try {
const data = await fetcher<Thing>({ path: "things/123" });
} catch (error) {
// error is already normalized to ApiError
console.error(error.message, error.status);
}Variations
SWR Integration (
If you use SWR instead of TanStack Query:
ts
import useSWR from "swr";
export const useAppFetcher = <T>(path: string) => {
return useSWR<T>(path, (url) => fetcher<T>({ path: url }));
};TanStack Query Integration
See Entity CRUD for query/mutation patterns.