@mrzr/api-client

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=b

Template 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 call

The 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

ValueBecomes
{ 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, falseKept

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]=desc

Transforming 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
});
  • beforeFunc can return a FormData or Blob, and it's sent as such.
  • beforeSelectOptions and afterFunc run 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 with status: false and 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:

  1. 204 / 205, or an empty body: data is undefined.
  2. A non-text content-type (images, PDFs, Office files, application/octet-stream): a Blob, byte-exact.
  3. 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 here

Don'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:

statusCodemessageCause
0The 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 timeout

Useful per-request options

OptionDoes
timeoutTimeout in ms for this call
headersExtra headers, merged over the client's
skipAuthDon't send the token and don't refresh first (public endpoints)
refreshTokenCheckfalse returns a 401 as-is instead of refreshing
fullDataDon't unwrap { data }
responseTypeHow to read the body
stringifyBodyfalse passes the body to fetch untouched
isFormDataSend as multipart. A plain-object body is converted to FormData (files, arrays, nested objects)
logLog 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.

On this page