Why Courier?
Native fetch is excellent but low-level. Courier adds typed path params, a query cache, tracked mutations, SSE, readable streaming, and a shared interceptor pipeline without external dependencies.
ts
// Before — raw fetch
const res = await fetch(`https://api.example.com/users/${userId}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const user: User = await res.json();
// After — Courier
const client = createCourier({ baseUrl: 'https://api.example.com' });
const user = await client.api.get<User>('/users/{id}', { params: { id: userId } });| Feature | Courier | axios | ky |
|---|---|---|---|
| Bundle size | 7.4 KB | ~26 kB | ~5 kB |
| Built on | fetch | XMLHttpRequest | fetch |
| Type-safe path params | Manual | Manual | |
| Query cache | |||
| SSE + streaming | |||
| Standalone mutations | |||
| Zero dependencies |
Use Courier when your app needs typed HTTP, a query cache, tracked mutations, or SSE — especially when you want all of these sharing one interceptor pipeline and zero extra dependencies.
Consider axios when you need to support IE11 or other XMLHttpRequest-based environments, or you already have a large axios-specific codebase.
Installation
sh
pnpm add @vielzeug/couriersh
npm install @vielzeug/couriersh
yarn add @vielzeug/courierQuick Start
ts
import { createCourier } from '@vielzeug/courier';
type NewUser = { name: string };
type User = { id: number; name: string };
const client = createCourier({
baseUrl: 'https://api.example.com',
query: { staleTime: 30_000 },
});
const user = await client.query.fetch({
key: ['users', 42],
fn: ({ signal }) => client.api.get<User>('/users/{id}', { params: { id: 42 }, signal }),
});
const createUser = client.mutation((input: NewUser, signal) =>
client.api.post<User>('/users', { body: input, signal }),
);
const nextUser = await createUser.mutate({ name: 'Alice' });
client.query.set(['users', nextUser.id], nextUser);
client.query.invalidate(['users']);Features
- Unified client —
createCourier()combinesapi,stream,query, andmutation()behind one shared transport - HTTP client —
createApi()with base URL, global headers, interceptors, timeout, deduplication, andcancelAll() - SSE —
createStream().sse()with typed events,Last-Event-IDreconnects, and shared interceptors - Readable HTTP streams —
stream.readable()for raw text or NDJSON chunk parsing - Query cache —
createQuery()withfetch(), prefix invalidation, background revalidation, and stable query keys - SyncStore integration —
query.observe()(watch + fetch in one call),query.watchKey(),query.observeMany(), andmutation.storework with React, Vue, and Svelte adapters;observe()acceptsplaceholderData,select, andfetch: falseviaObserveOptions - Standalone mutations —
createMutation()with retry, lifecycle callbacks, cancellation, and observable state - Request deduplication — idempotent requests dedupe by method + URL + response type, with
dedupe: falseto opt out - DataLoader-style batcher — coalesce N individual
load()calls into one batch via the internal batcher API - Interceptor presets —
withBearerAuth(),withRequestId(), andwithLogging()ready to plug in viause() - Focus/reconnect binding —
bindRefetch(qc)wires up tab visibility and network events; fully opt-in - Cache persistence —
persistQueryCache()andhydrateQueryCache()for cross-reload cache survival - Structured errors — distinct
HttpError,NetworkError,TimeoutError, andAbortErrorclasses for precise handling - Disposable — clients implement
[Symbol.dispose]for deterministic cleanup