createFetcher
Use the framework-agnostic fetcher from @use-q/api-client standalone — in Node CLIs, edge workers, RSC, and server actions.
createFetcher is the framework-agnostic core of use-q. It takes a configuration object (base URL, headers, error handling) and returns a small FetcherInstance whose fetch method resolves URLs, encodes bodies, and normalizes errors. Use it anywhere a QueryClient would be overkill — Node scripts, edge workers, server actions, React Server Components, route loaders.
import { createFetcher } from "@use-q/api-client";
import type { Post } from "./schema";
const fetcher = createFetcher({
baseUrl: "https://api.example.com",
});
const posts = await fetcher.fetch<Post[]>("/facilities/{facilityId}/posts", {
params: { facilityId: "f1" },
});
// ^? Post[]createFetcher doesn't take a schema — it's a low-level, path-based HTTP client. Route-aware, schema-typed calls are what createApiClient and its hooks layer on top (they call this same fetcher internally). See createApiClient.
Returned shape
createFetcher returns a FetcherInstance:
interface FetcherInstance {
baseUrl: string;
fetch<TResponse = unknown>(
path: string,
options?: {
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; // default "GET"
params?: Record<string, string | number | boolean>; // fills {placeholders}
searchParams?: SearchParams;
body?: unknown;
signal?: AbortSignal;
headers?: HeadersInit; // per-call overrides
},
): Promise<TResponse>;
}The path may contain {param} placeholders, which are filled (URI-encoded) from params — a missing placeholder value throws. searchParams are appended to the URL; undefined/null values are skipped and array values become repeated keys (?id=1&id=2). For POST/PUT/PATCH/DELETE with a body, the body is JSON.stringify-ed and Content-Type: application/json is added unless you supplied your own. Empty response bodies resolve to undefined; JSON responses are parsed when the content-type is application/json, everything else resolves as text.
If path is an absolute http(s):// URL, baseUrl joining is skipped and the URL is used as-is.
HTTP failures throw an ApiError — see Error handling.
Options
baseUrl
Prepended to every request path. Trailing slashes are normalized.
createFetcher({ baseUrl: "https://api.example.com/v1" });fetch
Inject a custom fetch implementation. Useful for tests, custom runtimes, or wrapping fetch with retry/auth/logging middleware. Defaults to globalThis.fetch.
import { fetch as undiciFetch } from "undici";
const fetcher = createFetcher({
baseUrl: "https://api.example.com",
fetch: undiciFetch,
});headers
Headers attached to every request. Any HeadersInit works (plain object, Headers instance, entry array), or a sync/async function returning one — the function is called and awaited on every request, so it's a great place to read a token from a store.
// Static
createFetcher({
baseUrl: "https://api.example.com",
headers: {
"x-api-version": "2026-01-01",
},
});
// Dynamic / async
createFetcher({
baseUrl: "https://api.example.com",
headers: async () => ({
Authorization: `Bearer ${await getAccessToken()}`,
"x-tenant-id": currentTenantId(),
}),
});Per-call headers overrides win over defaults:
await fetcher.fetch<Post>("/facilities/{facilityId}/posts/{postId}", {
params: { facilityId: "f1", postId: "p1" },
headers: { "x-debug": "1" },
});parseError
Convert error responses into your domain's error shape. parseError runs only for non-2xx responses and receives the raw Response plus the already-parsed body:
parseError?: (input: { response: Response; data: unknown }) => unknown;Whatever you return becomes the thrown ApiError's .data. If you return an ApiError instance, it's thrown as-is (letting you fully control the error object).
import { isApiError } from "@use-q/api-client";
interface MyApiError {
code: string;
detail: string;
fieldErrors?: Record<string, string>;
}
const fetcher = createFetcher({
baseUrl: "https://api.example.com",
parseError: ({ response, data }) => {
const body = data as Partial<MyApiError> | null;
return {
code: body?.code ?? "unknown",
detail: body?.detail ?? response.statusText,
fieldErrors: body?.fieldErrors,
} satisfies MyApiError;
},
});
try {
await fetcher.fetch("/facilities/{facilityId}/posts", {
method: "POST",
params: { facilityId: "f1" },
body: { title: "" },
});
} catch (err) {
if (isApiError<MyApiError>(err)) {
console.error(err.data.code, err.data.fieldErrors);
}
}onError
A side-effecting hook called for every failure, after error normalization. For HTTP failures it receives the ApiError about to be thrown; for network failures (fetch itself rejecting) it receives the raw error. Useful for telemetry or auth fan-out.
import { isApiError } from "@use-q/api-client";
createFetcher({
baseUrl: "https://api.example.com",
onError: (err) => {
if (isApiError(err) && err.status === 401) {
logoutAndRedirect();
}
telemetry.captureException(err);
},
});onError receives a single argument — the error. HTTP ApiErrors carry url, method, status, and statusText fields, so contextual data is available on the error itself.
Recipes
Node CLI
#!/usr/bin/env node
import { createFetcher } from "@use-q/api-client";
import type { Post } from "./schema.js";
const fetcher = createFetcher({
baseUrl: process.env.API_BASE_URL!,
headers: () => ({
Authorization: `Bearer ${process.env.API_TOKEN!}`,
}),
});
const posts = await fetcher.fetch<Post[]>("/facilities/{facilityId}/posts", {
params: { facilityId: process.argv[2]! },
});
console.table(posts.map(({ id, title }) => ({ id, title })));Cloudflare Worker
import { createFetcher } from "@use-q/api-client";
import type { Post } from "./schema";
export default {
async fetch(req: Request, env: Env): Promise<Response> {
const fetcher = createFetcher({
baseUrl: env.API_BASE_URL,
fetch: fetch, // Workers global fetch
headers: { "x-internal": env.INTERNAL_KEY },
});
const posts = await fetcher.fetch<Post[]>("/facilities/{facilityId}/posts", {
params: { facilityId: "f1" },
});
return Response.json(posts);
},
} satisfies ExportedHandler<Env>;Next.js Server Action
"use server";
import { createFetcher } from "@use-q/api-client";
import { cookies } from "next/headers";
import type { Post } from "@/api/schema";
const fetcher = createFetcher({
baseUrl: process.env.API_BASE_URL!,
headers: async () => ({
Authorization: `Bearer ${cookies().get("token")?.value ?? ""}`,
}),
});
export async function createPost(facilityId: string, formData: FormData) {
return fetcher.fetch<Post>("/facilities/{facilityId}/posts", {
method: "POST",
params: { facilityId },
body: {
title: String(formData.get("title")),
body: String(formData.get("body")),
},
});
}React Router data loader
import { createFetcher } from "@use-q/api-client";
import type { LoaderFunctionArgs } from "react-router-dom";
import type { Post } from "./schema";
const fetcher = createFetcher({
baseUrl: import.meta.env.VITE_API_BASE_URL,
});
export async function postsLoader({ params, request }: LoaderFunctionArgs) {
return fetcher.fetch<Post[]>("/facilities/{facilityId}/posts", {
params: { facilityId: params.facilityId! },
signal: request.signal,
});
}Want to hydrate loader data into a React-side cache? See SSR & loaders.
Why not just use fetch?
You get four things fetch doesn't give you:
- URL building. Path placeholders are filled and URI-encoded (with a loud error when a value is missing),
undefined/nullsearch params are dropped, arrays become repeated keys, JSON bodies are serialized with the rightContent-Type. - Consistent error shape. Non-2xx responses always throw an
ApiError(optionally shaped by yourparseError). - Centralized headers and error hooks. Async header factories and a single
onErrorfor telemetry/auth, instead of copy-pasted boilerplate per call. - A swap-in path to React. The same
CreateFetcherOptionsare accepted bycreateApiClient, which pairs the fetcher with a schema for fully-typed hooks and caching.