Skip to content
courier logoCourierHTTP
Type-safe HTTP, query cache, mutations, SSE, and readable streaming built on native fetch.
v0.0.17.4 KB gzip Browser · Node.js · SSR · Deno
createApicreateCouriercreateMutationcreateQuerycreateStreamCourierErrorHttpErrorNetworkError +9 more →

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 } });
FeatureCourieraxiosky
Bundle size7.4 KB~26 kB~5 kB
Built onfetchXMLHttpRequestfetch
Type-safe path paramsManualManual
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/courier
sh
npm install @vielzeug/courier
sh
yarn add @vielzeug/courier

Quick 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 clientcreateCourier() combines api, stream, query, and mutation() behind one shared transport
  • HTTP clientcreateApi() with base URL, global headers, interceptors, timeout, deduplication, and cancelAll()
  • SSEcreateStream().sse() with typed events, Last-Event-ID reconnects, and shared interceptors
  • Readable HTTP streamsstream.readable() for raw text or NDJSON chunk parsing
  • Query cachecreateQuery() with fetch(), prefix invalidation, background revalidation, and stable query keys
  • SyncStore integrationquery.observe() (watch + fetch in one call), query.watchKey(), query.observeMany(), and mutation.store work with React, Vue, and Svelte adapters; observe() accepts placeholderData, select, and fetch: false via ObserveOptions
  • Standalone mutationscreateMutation() with retry, lifecycle callbacks, cancellation, and observable state
  • Request deduplication — idempotent requests dedupe by method + URL + response type, with dedupe: false to opt out
  • DataLoader-style batcher — coalesce N individual load() calls into one batch via the internal batcher API
  • Interceptor presetswithBearerAuth(), withRequestId(), and withLogging() ready to plug in via use()
  • Focus/reconnect bindingbindRefetch(qc) wires up tab visibility and network events; fully opt-in
  • Cache persistencepersistQueryCache() and hydrateQueryCache() for cross-reload cache survival
  • Structured errors — distinct HttpError, NetworkError, TimeoutError, and AbortError classes for precise handling
  • Disposable — clients implement [Symbol.dispose] for deterministic cleanup

Documentation

See Also

  • Spell — validate HTTP response payloads against typed schemas before they enter your cache
  • Forge — pair with Courier mutations to manage typed form state and submission
  • Ripple — use signal stores as a reactive layer on top of Courier's SyncStore API