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:
- Lightweight: Under 6KB. Zero dependencies. Pure ESM.
- Simple: Based on Fetch API, and close enough to
fetchthat it does not feel like learning another client from scratch. - Type-safe: Types can come from schemas, validators, or manual generics.
- 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.
| Problem | What usually happens | What CallApi gives you |
|---|---|---|
Repeated try/catch, response.ok, and response.json() code | Every API file grows its own version | { data, error, response } by default |
| Different error conventions | Fetch does not throw for HTTP errors, Axios/Ky/Ofetch do | Return errors by default, throw when you ask for it |
| Request dedupe added late | You add maps, abort controllers, cache keys, or custom request registries | Built-in request dedupe |
| Validation separated from fetching | You fetch first, then manually parse with Zod/Valibot/etc. | Schema validation on the request, with inferred data types |
| Messy URL building | Template strings and URLSearchParams spread around the app | params, query, and method prefixes |
| Auth/defaults drift | Different helpers set headers in slightly different ways | Base client options, auth helpers, hooks, and plugins |
| Wrapper code keeps growing | Retries, timeouts, parsing, hooks, validation, and error shaping get rebuilt per app | Those pieces are already in the client |
Comparison Table
Approximate minified + gzipped sizes.
| Feature | Raw fetch | Axios | Ky | Ofetch | CallApi |
|---|---|---|---|---|---|
| Size | 0KB | ~13KB | ~5KB | ~8KB | <6KB |
| API style | Platform primitive | Custom HTTP client | Chainable fetch wrapper | $fetch style wrapper | Fetch-like client |
| Fetch-based | Yes | No | Yes | Yes | Yes |
| Default result shape | Response | Throws/response | Throws/body helpers | Throws/body | { data, error, response } |
| Content-type aware response parsing | Manual | Partial | Yes | Yes | Yes |
| Request timeout | Manual | Yes | Yes | Yes | Yes |
| Retries | Manual | No | Yes | Yes | Yes |
| Request dedupe | Manual | No | No | No | Yes |
| Schema validation | Manual | No | No | No | Yes |
| Type inference from schemas | Manual | No | No | No | Yes |
| Hooks/interceptors | Manual | Yes | Yes | Yes | Yes |
| Plugin system | Manual | No | No | No | Yes |
| URL params/query helpers | Manual | Params only | Prefix/query helpers | Query helpers | Params, 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
| Axios | CallApi |
|---|---|
axios.create({ baseURL }) | createFetchClient({ baseURL }) |
axios.get("/users") | callApi("/users") or callApi("@get/users") |
axios.post("/users", body) | callApi("@post/users", { body }) |
response.data | result.data |
AxiosError | result.error or thrown HTTPError with throwOnError |
| Request/response interceptors | Lifecycle 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.
| Area | Ofetch | CallApi |
|---|---|---|
| Basic fetch wrapper | Yes | Yes |
| Nuxt/UnJS ecosystem fit | Strong | Neutral |
| Default error behavior | Throws | Returns structured result unless configured |
| Schema validation | Manual | Built in |
| Dedupe | Manual | Built in |
| Plugins | Manual composition | Plugin 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");Last updated on