Requests & Errors
Make GET, POST, PUT, PATCH and DELETE requests with @mrzr/api-client, build URLs and query strings, transform payloads and handle ApiError.
The five methods
api.get<R>(url, config?)
api.post<R>(url, body?, config?)
api.put<R>(url, body?, config?)
api.patch<R>(url, body?, config?)
api.delete<R>(url, config?)Plain objects are sent as JSON. Content-Type is only set when there is a body, so a cross-origin GET doesn't trigger a CORS preflight.
R types res.data. GET and DELETE take no body; send identifiers in the URL or query string instead: api.delete("/items", { params: { ids: [1, 2] } }).
URLs and query strings
api.get("/orgs/{org}/users", {
addTemplateToUrl: { org: "acme" }, // → /orgs/acme/users
params: { page: 1, filter: { active: true }, tag: ["a", "b"] },
});
// → /orgs/acme/users?page=1&filter[active]=true&tag=a&tag=bTemplate values are encoded as a single path segment, so { id: "1/../admin" } can't escape into another path.
addToUrl appends segments, each encoded, and ends with a trailing slash (the Django REST style):
api.get("/users", { addToUrl: [42, "posts"] }); // → /users/42/posts/A null, undefined or "" segment throws (addToUrl contains a falsy segment…), because it's almost always an unresolved ID. 0 is allowed.
Base URL
createClient({ baseUrl: "https://api.example.com/v1/" });
api.get("/users"); // → https://api.example.com/v1/users
api.get("users"); // → same: slashes are normalized, never doubled
api.get("https://cdn.example.com/manifest.json"); // absolute URLs skip baseUrl
api.get("/status", { baseUrl: "https://other.example.com" }); // override for one callThe token and CSRF header only go to the baseUrl origin, the page's origin and authOrigins. The CDN request above goes out without them.
How params are serialized
| Value | Becomes |
|---|---|
{ q: "ada" } | q=ada |
{ filter: { active: true } } | filter[active]=true |
{ tag: ["a", "b"] } | tag=a&tag=b |
{ since: new Date(…) } | ISO 8601 string |
null, undefined, "" | Dropped, at every depth |
0, false | Kept |
Dropping empty values means an empty search box doesn't send q=. If the URL already has a query string, params are appended with &. The serializer is exported as buildQueryString.
List endpoints get a typed ordering param, see TypeScript:
api.get<ListResponse<User>>("/users", { params: { ordering: { createdAt: "desc" } } });
// → /users?ordering[createdAt]=descTransforming data
await api.post<User>("/users", input, {
beforeFunc: (body) => ({ ...body, tenant: "acme" }), // outgoing, before serialization
beforeSelectOptions: (data) => data, // incoming, runs first
afterFunc: (data) => camelCaseKeys(data), // incoming, runs last
});beforeFunccan return aFormDataorBlob, and it's sent as such.beforeSelectOptionsandafterFuncrun only on success. They're written for the success shape, so they'd crash on an error body. If one throws, the result keeps its status code withstatus: falseand the error's message.- In worker mode these functions run on the page, not in the worker. Behaviour is identical.
beforeSelectOptions is meant for shaping lists into { value, label } options for a select input. It's a plain function, so you can use it for any mapping.
Uploading files
Pass isFormData: true with a plain object and the client builds the FormData:
await api.post("/profile", {
name: "Ada",
avatar: input.files[0], // File / Blob → a file part
documents: [file1, file2], // array → one part per item
settings: { theme: "dark" }, // nested object → JSON string
nickname: undefined, // null / undefined → skipped
}, { isFormData: true });Numbers and booleans become text and a Date becomes an ISO string. A real FormData, a string, bytes or a class instance is sent as is. A file nested inside an object can't be sent as text, so the call fails before anything is sent and names the field: keep files at the top level or in a top-level array. The form is built once, so a 401 retry resends it, and it works in worker mode too.
What you get back
Every call resolves to the same envelope:
const res = await api.get<User>("/me");
res.data // the payload: unwrapped from { data } when the server wraps it
res.body // the whole body, when data was unwrapped (pagination meta, links…)
res.statusCode // 200
res.status // true for 2xx
res.message // the server's "message", if any
res.errors // the server's "errors" object, if any
res.headers // response headers, lowercased keys: res.headers?.["x-total-count"]With the default responseType: "auto" the body is read forgivingly:
204/205, or an empty body:dataisundefined.- A non-text
content-type(images, PDFs, Office files,application/octet-stream): aBlob, byte-exact. - Anything else is tried as JSON, even with a wrong
content-type, and falls back to the raw string.
So a plain-text 500 Internal Server Error page never crashes your error handler. Pass responseType: "json" | "text" | "blob" | "arrayBuffer" to choose the format yourself, or fullData: true to keep the wrapper in data.
Errors
By default a failure throws an ApiError. This is what TanStack Query, SWR and Vue Query expect.
try {
await api.post("/users", form);
} catch (e) {
if (e instanceof ApiError) {
e.statusCode // 422
e.message // "Validation failed"
e.errors // { email: ["already taken"] }
e.data // the parsed body
e.response // the full envelope, headers included
}
}message and errors are read from the top level of the response body. The recommended server shape is:
{ "message": "Validation failed", "errors": { "email": ["already taken"] } }Mapping those onto a form:
catch (e) {
if (e instanceof ApiError && e.statusCode === 422) {
for (const [field, messages] of Object.entries(e.errors ?? {})) setFieldError(field, messages[0]);
}
}If you prefer to check a flag over catching errors, turn throwing off for one call or for the whole client. Per-request always wins:
const res = await api.get("/users", { throwError: false });
if (!res.status) showError(res.message);
const api = createClient({ throwError: false }); // envelope everywhere
await api.get("/critical", { throwError: true }); // except hereDon't use throwError: false with TanStack Query or SWR. A 500 would resolve normally and get cached as data.
Codes the client sets
Some failures never reach the server, so the client sets the status code itself:
statusCode | message | Cause |
|---|---|---|
0 | The runtime's error, e.g. "Failed to fetch" | Offline, DNS, CORS, bad URL |
0 | "Request aborted" / "Request canceled: …" | Your signal, cancel() or destroy(). Sets canceled: true, and resolves instead of throwing |
408 | "Request timed out" | The timeout ran out. A real failure: it throws |
One place for every error
onError fires for every failed request, whatever throwError says. It never fires for a canceled request. Use it for toasts and error reporting; skip it for one call with hideErrorMessage: true:
const api = createClient({
onError: (res) => {
if (res.statusCode === 0) toast("You're offline");
else if (res.statusCode >= 500) Sentry.captureMessage(res.message);
},
});
await api.get("/feature-flags", { hideErrorMessage: true, throwError: false });See Logging & Monitoring for more.
Timeouts
The default is 30 s per attempt: a request that gets a 401, refreshes and retries gets a fresh budget for the retry. The timeout also covers downloading the body.
createClient({ timeout: 10_000 }); // client-wide
await api.get("/report", { timeout: 60_000 }); // this call
await api.post("/upload", form, { timeout: 0 }); // no timeoutUseful per-request options
| Option | Does |
|---|---|
timeout | Timeout in ms for this call |
headers | Extra headers, merged over the client's |
skipAuth | Don't send the token and don't refresh first (public endpoints) |
refreshTokenCheck | false returns a 401 as-is instead of refreshing |
fullData | Don't unwrap { data } |
responseType | How to read the body |
stringifyBody | false passes the body to fetch untouched |
isFormData | Send as multipart. A plain-object body is converted to FormData (files, arrays, nested objects) |
log | Log this call through onLog |
signal is merged with the timeout, so whichever fires first wins. These fetch options are passed through: cache, integrity, keepalive, mode, redirect, referrer, referrerPolicy. credentials is client-wide only, so auth stays consistent. For every option, see Client options.
Live Demo
Run @mrzr/api-client in your browser. Watch many 401s trigger a single token refresh, cancel in-flight requests and check where the token is kept.
Authentication
Log users in with bearer tokens or httpOnly cookies. Choose where tokens are stored, restore a cookie session on page load and enable CSRF protection.