Custom Handlers
Override the default JSON fetch behavior for special cases.
When to Use
Use custom handlers for:
- File uploads — FormData instead of JSON
- File downloads — Blob / ArrayBuffer responses
- Non-JSON APIs — XML, plain text, etc.
- Response transformation — Reshape data before returning
For most use cases (auth, logging, retries), prefer hooks or plugins.
Handler Signature
The handler is passed as the second argument to any endpoint helper:
get<TInput, TOutput>(path, handler, hooks?)The handler receives:
async (context: {
input: TInput; // The input passed to the endpoint call
fetch: typeof fetch; // ⚠️ Always use this — not window.fetch — to preserve hooks
method: HttpMethod;
path: string;
baseUrl: string;
}) => Promise<TOutput>File Upload
const api = createApiClient({
upload: post<{ file: File; name: string }, { id: string; url: string }>(
'/files',
async ({ input, fetch, baseUrl, path }) => {
const formData = new FormData();
formData.append('file', input.file);
formData.append('name', input.name);
// Do NOT set Content-Type manually — the browser adds it with the correct boundary
const response = await fetch(`${baseUrl}${path}`, { method: 'POST', body: formData });
if (!response.ok) throw { status: response.status, statusText: response.statusText, error: await response.json() };
return response.json();
}
),
}, { baseUrl: 'https://api.example.com' });
const result = await api.upload({ file: myFile, name: 'report.pdf' });
console.log(result.url);File Download
const api = createApiClient({
download: get<{ id: string }, Blob>(
(input) => `/files/${input.id}`,
async ({ fetch, baseUrl, path }) => {
const response = await fetch(`${baseUrl}${path}`, { method: 'GET' });
if (!response.ok) throw { status: response.status, statusText: response.statusText, error: null };
return response.blob();
}
),
}, { baseUrl: 'https://api.example.com' });
const blob = await api.download({ id: 'file-123' });
const url = URL.createObjectURL(blob);Response Transformation
Transform or reshape the API response before returning:
const api = createApiClient({
getUser: get<{ id: string }, User>(
(input) => `/users/${input.id}`,
async ({ fetch, baseUrl, path }) => {
const response = await fetch(`${baseUrl}${path}`, { method: 'GET' });
const raw = await response.json();
// Rename snake_case → camelCase
return {
id: raw.id,
name: `${raw.first_name} ${raw.last_name}`,
email: raw.email_address,
createdAt: new Date(raw.created_at),
};
}
),
}, { baseUrl: 'https://api.example.com' });Non-JSON Responses
const api = createApiClient({
getText: get<{ id: string }, string>(
(input) => `/content/${input.id}`,
async ({ fetch, baseUrl, path }) => {
const response = await fetch(`${baseUrl}${path}`, { method: 'GET' });
if (!response.ok) throw { status: response.status, statusText: response.statusText, error: null };
return response.text();
}
),
}, { baseUrl: 'https://api.example.com' });Always use the
fetchfrom the context object — it has all hooks applied. Usingwindow.fetchor a globalfetchdirectly bypasses hooks.