Migration Guide
Migrate to CallApi from Axios, Ky, Ofetch, or raw fetch
Most migrations are mechanical. The main thing is deciding how you want errors to work.
- Axios, Ky, and Ofetch usually throw for HTTP errors.
- Raw fetch does not throw for HTTP errors.
- CallApi returns
{ data, error, response }by default. - If you want throwing behavior, use
throwOnError: true.
Install
npm install @zayne-labs/callapiWhat Usually Changes
| Before | CallApi |
|---|---|
axios.create(...), ky.create(...), ofetch.create(...) | createFetchClient(...) |
.get("/users") | callApi("/users") or callApi("@get/users") |
.post("/users", body) | callApi("@post/users", { body }) |
response.data | result.data |
.json<T>() | schema or manual generic |
try/catch for HTTP errors | result.error by default |
| Interceptors/hooks | onRequest, onSuccess, onResponseError, onError, plugins |
| Manual path interpolation | params |
| Manual query strings | query |
Before You Touch Every Call
Set up a base client first. Move the shared bits there: baseURL, timeout, retries, headers, auth, and hooks.
import { createFetchClient } from "@zayne-labs/callapi";
export const callApi = createFetchClient({
baseURL: "https://api.example.com",
timeout: 10_000,
retryAttempts: 2,
onRequest: ({ options }) => {
options.auth = getToken();
},
});After that, most call sites should only care about the path, body, params, query, and schema.
From Axios
Axios migration mostly means replacing method helpers and deciding whether you want result objects or thrown errors.
Client Setup
import axios from "axios";
export const api = axios.create({
baseURL: "https://api.example.com",
timeout: 10_000,
headers: {
"X-App": "dashboard",
},
});Requests
const usersResponse = await api.get("/users");
const users = usersResponse.data;
const userResponse = await api.post("/users", {
name: "Ada",
});
const user = userResponse.data;Interceptors to Hooks
api.interceptors.request.use((config) => {
config.headers.Authorization = `Bearer ${getToken()}`;
return config;
});
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
redirectToLogin();
}
return Promise.reject(error);
}
);Error Handling
const getUsers = async () => {
try {
const response = await api.get("/users");
return response.data;
} catch (error) {
if (axios.isAxiosError(error)) {
console.error(error.response?.data);
}
}
};From Ky
Ky is already fetch-based. The main change is moving away from chains like .json() and into CallApi's result object.
import ky from "ky";
const api = ky.create({
prefixUrl: "https://api.example.com",
retry: 2,
timeout: 10_000,
});
const user = await api
.post("users", {
json: { name: "Ada" },
})
.json<User>();Ky Hooks
const api = ky.create({
hooks: {
beforeRequest: [
(request) => {
request.headers.set("Authorization", `Bearer ${token}`);
},
],
afterResponse: [
(_request, _options, response) => {
console.log(response.status);
},
],
},
});From Ofetch
Ofetch is close to CallApi. The biggest difference is that Ofetch throws by default, while CallApi returns a result object by default.
import { ofetch } from "ofetch";
const api = ofetch.create({
baseURL: "https://api.example.com",
retry: 2,
retryDelay: 1000,
});
const user = await api("/users/1");Ofetch Hooks
const api = ofetch.create({
onRequest: ({ options }) => {
options.headers = {
...options.headers,
Authorization: `Bearer ${token}`,
};
},
onResponseError: ({ response }) => {
console.error(response.status);
},
});From Raw Fetch
Raw fetch migration is mostly deleting the wrapper code you keep repeating.
const createUser = async () => {
const response = await fetch("/api/users", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ name: "Ada" }),
});
const payload = await response.json();
if (!response.ok) {
return { data: null, error: payload };
}
return { data: payload, error: null };
};Add Validation Where It Matters
Do not add schemas everywhere on day one. Start with places where bad data is actually painful: auth, permissions, payments, user profiles, and shared API clients.
import { z } from "zod";
const userSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
});
const { data: user, error } = await callApi("/users/:id", {
params: { id: 1 },
schema: {
data: userSchema,
},
});Common Refactors
Query Parameters
const { data } = await callApi("/users", {
query: {
role: "admin",
active: true,
status: ["active", "invited"],
},
});Arrays become repeated keys. Objects are JSON-stringified. URLSearchParams is also accepted.
URL Parameters
const { data } = await callApi("/users/:id/posts/{postId}", {
params: {
id: 1,
postId: 10,
},
});Both :param and {param} placeholders work. CallApi URL-encodes each value before adding it to the
path.
File Uploads
const formData = new FormData();
formData.append("avatar", file);
const { data } = await callApi("@post/users/:id/avatar", {
body: formData,
params: { id: 1 },
});Final Considerations/Checklist
- Replace imports.
- Replace client creation.
- Replace method helpers.
- Convert interceptors/hooks.
- Decide if each call should return
{ data, error }or throw. - Set an absolute
baseURLfor relative requests that can run during SSR or in Node.js. - Add schemas where runtime validation is worth it.
- Use
paramsandqueryinstead of manual string building. - Keep server-state caching in TanStack Query, SWR, or your framework. Use CallApi underneath as the request client.
Last updated on