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/callapi

What Usually Changes

BeforeCallApi
axios.create(...), ky.create(...), ofetch.create(...)createFetchClient(...)
.get("/users")callApi("/users") or callApi("@get/users")
.post("/users", body)callApi("@post/users", { body })
response.dataresult.data
.json<T>()schema or manual generic
try/catch for HTTP errorsresult.error by default
Interceptors/hooksonRequest, onSuccess, onResponseError, onError, plugins
Manual path interpolationparams
Manual query stringsquery

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 baseURL for relative requests that can run during SSR or in Node.js.
  • Add schemas where runtime validation is worth it.
  • Use params and query instead of manual string building.
  • Keep server-state caching in TanStack Query, SWR, or your framework. Use CallApi underneath as the request client.
Edit on GitHub

Last updated on

On this page