Skip to content

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.