Comparisons

How is CallApi different from other existing fetching libraries?

CallApi is not a React Query, SWR, Apollo, or framework loader replacement. Those handle caching and server state.

CallApi sits lower. It is for the HTTP call itself which covers:

  • Building the URL
  • Sending the request
  • Parsing the response
  • Handling errors
  • Retrying
  • Dedupe
  • Validating
  • Running hooks, etc

Philosophy

CallApi is built around four core principles:

  1. Lightweight: Under 6KB. Zero dependencies. Pure ESM.
  2. Simple: Based on Fetch API, and close enough to fetch that it does not feel like learning another client from scratch.
  3. Type-safe: Types can come from schemas, validators, or manual generics.
  4. Extensible: Hooks and plugins are there when the base client is not enough.

Pain Points CallApi Covers

These are the things that usually make a small fetch helper keep growing.

ProblemWhat usually happensWhat CallApi gives you
Repeated try/catch, response.ok, and response.json() codeEvery API file grows its own version{ data, error, response } by default
Different error conventionsFetch does not throw for HTTP errors, Axios/Ky/Ofetch doReturn errors by default, throw when you ask for it
Request dedupe added lateYou add maps, abort controllers, cache keys, or custom request registriesBuilt-in request dedupe
Validation separated from fetchingYou fetch first, then manually parse with Zod/Valibot/etc.Schema validation on the request, with inferred data types
Messy URL buildingTemplate strings and URLSearchParams spread around the appparams, query, and method prefixes
Auth/defaults driftDifferent helpers set headers in slightly different waysBase client options, auth helpers, hooks, and plugins
Wrapper code keeps growingRetries, timeouts, parsing, hooks, validation, and error shaping get rebuilt per appThose pieces are already in the client

Comparison Table

Approximate minified + gzipped sizes.

FeatureRaw fetchAxiosKyOfetchCallApi
Size0KB~13KB~5KB~8KB<6KB
API stylePlatform primitiveCustom HTTP clientChainable fetch wrapper$fetch style wrapperFetch-like client
Fetch-basedYesNoYesYesYes
Default result shapeResponseThrows/responseThrows/body helpersThrows/body{ data, error, response }
Content-type aware response parsingManualPartialYesYesYes
Request timeoutManualYesYesYesYes
RetriesManualNoYesYesYes
Request dedupeManualNoNoNoYes
Schema validationManualNoNoNoYes
Type inference from schemasManualNoNoNoYes
Hooks/interceptorsManualYesYesYesYes
Plugin systemManualNoNoNoYes
URL params/query helpersManualParams onlyPrefix/query helpersQuery helpersParams, query, method prefixes

Raw Fetch

Raw fetch is fine until the same request code starts showing up everywhere.

const baseURL = process.env.NEXT_PUBLIC_API_URL;

const response = await fetch(`${baseURL}/api/users`);

if (!response.ok) {
	const errorData = await response.json();
	throw new Error(errorData.message);
}

const users = await response.json();

With CallApi:

import { callApi } from "@zayne-labs/callapi";

const { data: users, error } = await callApi("/api/users", {
	baseURL: process.env.NEXT_PUBLIC_API_URL,
});

if (error) {
	console.error(error.message, error.errorData);
}

Keep raw fetch if the wrapper stays tiny. Use CallApi when that wrapper starts growing retries, parsing, validation, auth, dedupe, and shared error handling.

Axios

Axios is still fine. It just has its own request and response model, while CallApi stays closer to fetch.

What CallApi Adds Here

  • Retry logic and dedupe live in the client instead of wrapper code.
  • Schema validation can live on the request instead of after the request.
  • params, query, and method prefixes reduce URL string building.
  • The default result object lets you handle expected API errors without wrapping every call in try/catch.

API Mapping

AxiosCallApi
axios.create({ baseURL })createFetchClient({ baseURL })
axios.get("/users")callApi("/users") or callApi("@get/users")
axios.post("/users", body)callApi("@post/users", { body })
response.dataresult.data
AxiosErrorresult.error or thrown HTTPError with throwOnError
Request/response interceptorsLifecycle hooks and plugins
import axios from "axios";

const api = axios.create({
	baseURL: "https://api.example.com",
	timeout: 10_000,
});

api.interceptors.request.use((config) => {
	config.headers.Authorization = `Bearer ${getToken()}`;
	return config;
});

const response = await api.post("/users", {
	name: "Ada",
});

const user = response.data;
import { createFetchClient } from "@zayne-labs/callapi";

const callApi = createFetchClient({
	baseURL: "https://api.example.com",
	timeout: 10_000,
	onRequest: ({ options }) => {
		options.auth = getToken();
	},
});

const { data: user, error } = await callApi("@post/users", {
	body: { name: "Ada" },
});

Stick with Axios if the app is deeply built around it. CallApi makes more sense when you want fetch plus the request-layer pieces you would otherwise add yourself.

Ky

Ky is the closest comparison if you only care about size and fetch support. It is also a smaller mental model: chain the request, call .json<T>(), move on.

What CallApi Adds Here

  • Ky's .json<T>() is a type assertion. CallApi can infer from a schema and validate at runtime.
  • Request dedupe, plugins, URL params, and result modes are in CallApi.
  • CallApi fits better when the request layer needs shared behavior, not just nicer syntax.
import ky from "ky";

const api = ky.create({
	prefixUrl: "https://api.example.com",
	retry: 2,
});

const user = await api.get("users/1").json<User>();
import { createFetchClient } from "@zayne-labs/callapi";
import { z } from "zod";

const callApi = createFetchClient({
	baseURL: "https://api.example.com",
	retryAttempts: 2,
});

const { data: user } = await callApi("/users/:id", {
	params: { id: 1 },
	schema: {
		data: z.object({
			id: z.number(),
			name: z.string(),
		}),
	},
});

Use Ky when you want the smaller tool. Use CallApi when you want the request lifecycle handled in one place.

Ofetch

Ofetch and CallApi are close. Ofetch fits especially well if you already use Nuxt or other UnJS tools.

What CallApi Adds Here

  • Validation, dedupe, and plugin-style extension are not the main things Ofetch is solving.
  • Ofetch throws by default. CallApi defaults to a result object, which is easier for expected API errors like validation failures.
  • CallApi puts params, query, schemas, hooks, result modes, and request defaults on the same request model.
AreaOfetchCallApi
Basic fetch wrapperYesYes
Nuxt/UnJS ecosystem fitStrongNeutral
Default error behaviorThrowsReturns structured result unless configured
Schema validationManualBuilt in
DedupeManualBuilt in
PluginsManual compositionPlugin API
import { ofetch } from "ofetch";

const api = ofetch.create({
	baseURL: "https://api.example.com",
	retry: 2,
	onRequest: ({ options }) => {
		options.headers = {
			...options.headers,
			Authorization: `Bearer ${token}`,
		};
	},
});

const users = await api("/users");
import { createFetchClient } from "@zayne-labs/callapi";

const callApi = createFetchClient({
	baseURL: "https://api.example.com",
	retryAttempts: 2,
	onRequest: ({ options }) => {
		options.auth = token;
	},
});

const { data: users } = await callApi("/users");
Edit on GitHub

Last updated on

On this page