Skip to content

createFetcher

createFetcher is the framework-agnostic core of use-q. It takes the same Schema you’d use with the React layer and returns a fetch function that’s fully typed end-to-end. 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 { schema } from "./schema";
const fetcher = createFetcher<typeof schema>(schema, {
baseUrl: "https://api.example.com",
});
const posts = await fetcher.fetch("listPosts", {
pathParams: { facilityId: "f1" },
});
// ^? Post[]

createFetcher returns a FetcherInstance with one method:

fetcher.fetch(routeName, {
pathParams?,
searchParams?,
body?,
signal?,
headers?, // per-call overrides
});

The return type is the schema’s response for that route. Errors throw an ApiError (or your custom parseError output) — see Error handling.

Prepended to every request path. Trailing slashes are normalized.

createFetcher(schema, { baseUrl: "https://api.example.com/v1" });

Inject a custom fetch implementation. Useful for tests, Node 16, or wrapping fetch with retry/auth/logging middleware.

import { fetch as undiciFetch } from "undici";
const fetcher = createFetcher(schema, {
baseUrl: "https://api.example.com",
fetchFn: undiciFetch,
});

Headers attached to every request. Can be a static object or an async function — the function is awaited on every call, so it’s a great place to read a token from a store.

// Static
createFetcher(schema, {
baseUrl: "https://api.example.com",
defaultHeaders: {
"x-api-version": "2026-01-01",
},
});
// Dynamic / async
createFetcher(schema, {
baseUrl: "https://api.example.com",
defaultHeaders: async () => ({
Authorization: `Bearer ${await getAccessToken()}`,
"x-tenant-id": currentTenantId(),
}),
});

Per-call headers overrides win over defaults:

await fetcher.fetch("getPost", {
pathParams: { facilityId: "f1", postId: "p1" },
headers: { "x-debug": "1" },
});

Convert error responses into your domain’s error type. parseError runs only for non-2xx responses and receives the raw Response and parsed JSON (if any).

interface MyApiError {
code: string;
detail: string;
fieldErrors?: Record<string, string>;
}
const fetcher = createFetcher<typeof schema, MyApiError>(schema, {
baseUrl: "https://api.example.com",
parseError: async (res) => {
const body = (await res.json().catch(() => null)) as MyApiError | null;
return {
code: body?.code ?? "unknown",
detail: body?.detail ?? res.statusText,
fieldErrors: body?.fieldErrors,
};
},
});
try {
await fetcher.fetch("createPost", {
pathParams: { facilityId: "f1" },
body: { title: "" },
});
} catch (err) {
if (isApiError<MyApiError>(err)) {
console.error(err.parsed.code, err.parsed.fieldErrors);
}
}

A side-effecting hook called for every failure (after parseError). Useful for telemetry or auth fan-out.

import { isApiError } from "@use-q/api-client";
createFetcher(schema, {
baseUrl: "https://api.example.com",
onError: (err, ctx) => {
if (isApiError(err) && err.status === 401) {
logoutAndRedirect();
}
telemetry.captureException(err, {
tags: { route: ctx.routeName, method: ctx.method },
});
},
});

The second argument is a context object: { routeName, method, url, status }.

#!/usr/bin/env node
import { createFetcher } from "@use-q/api-client";
import { schema } from "./schema.js";
const fetcher = createFetcher<typeof schema>(schema, {
baseUrl: process.env.API_BASE_URL!,
defaultHeaders: () => ({
Authorization: `Bearer ${process.env.API_TOKEN!}`,
}),
});
const posts = await fetcher.fetch("listPosts", {
pathParams: { facilityId: process.argv[2]! },
});
console.table(posts.map(({ id, title }) => ({ id, title })));
import { createFetcher } from "@use-q/api-client";
import { schema } from "./schema";
export default {
async fetch(req: Request, env: Env): Promise<Response> {
const fetcher = createFetcher<typeof schema>(schema, {
baseUrl: env.API_BASE_URL,
fetchFn: fetch, // Workers global fetch
defaultHeaders: { "x-internal": env.INTERNAL_KEY },
});
const posts = await fetcher.fetch("listPosts", {
pathParams: { facilityId: "f1" },
});
return Response.json(posts);
},
} satisfies ExportedHandler<Env>;
"use server";
import { createFetcher } from "@use-q/api-client";
import { cookies } from "next/headers";
import { schema } from "@/api/schema";
const fetcher = createFetcher<typeof schema>(schema, {
baseUrl: process.env.API_BASE_URL!,
defaultHeaders: async () => ({
Authorization: `Bearer ${cookies().get("token")?.value ?? ""}`,
}),
});
export async function createPost(facilityId: string, formData: FormData) {
return fetcher.fetch("createPost", {
pathParams: { facilityId },
body: {
title: String(formData.get("title")),
body: String(formData.get("body")),
},
});
}
import { createFetcher } from "@use-q/api-client";
import type { LoaderFunctionArgs } from "react-router-dom";
import { schema } from "./schema";
const fetcher = createFetcher<typeof schema>(schema, {
baseUrl: import.meta.env.VITE_API_BASE_URL,
});
export async function postsLoader({ params, request }: LoaderFunctionArgs) {
return fetcher.fetch("listPosts", {
pathParams: { facilityId: params.facilityId! },
signal: request.signal,
});
}

You get four things fetch doesn’t give you:

  1. Static guarantees. Misspell a route name, miss a path param, send the wrong body shape — TypeScript catches it.
  2. URL building. Path placeholders are filled, undefined search params are dropped, JSON bodies are serialized.
  3. Consistent error shape. Non-2xx responses always throw an ApiError (or your parseError output).
  4. A swap-in path to React. The same schema works with createApiClient for hooks and cache.