Overview
Courier provides two ways to call your API endpoints. Both share the same TypedClient interface and the same type inference:
| Factory | Mode | Use Case |
|---|---|---|
createCaller(api) | Direct | Server-side, Node.js, Bun, Deno, scripts |
createProxyCaller(apiRoutes) | Proxy | Browser → your server → backend |
createCaller (Direct Mode) direct mode
Calls your backend directly. Works in any JavaScript runtime with fetch.
import { createCaller } from "@noverax/courier"
import { api } from "@/api/sources"
export const caller = createCaller(api)// Works anywhere: Node.js, Bun, Deno, Server Components, scripts
const users = await caller.users.getAll()
// ^? User[]
const user = await caller.users.getById({ id: "1" })
// ^? UserWhen to use direct mode
Use direct mode for server-side code where you have access to the api object: Server Components, Server Actions, API routes, CLI scripts, cron jobs.
createProxyCaller (Proxy Mode) proxy mode
Sends requests through your server's proxy route. The browser never sees your backend URL. The caller takes the routes Courier writes into courier.routes.ts from the endpoints you marked .expose(): what a page may call, and how it reaches each — see Exposing Endpoints.
import { createProxyCaller } from "@noverax/courier"
import { apiRoutes } from "@/courier.routes"
export const caller = createProxyCaller(apiRoutes, {
proxyPath: { http: "/api/request" }, // optional, defaults to /api/request
timeout: 10000,
credentials: "include", // send the browser's cookies to the proxy route
headers: { "X-Custom": "value" },
onError: (error) => console.error(error),
onSuccess: (data) => console.log(data),
})// Browser sends POST /api/request → your server → backend
// (a read that declares .cache() goes as GET)
const users = await caller.users.getAll()The routes, not the API
Pass the routes, never the api object: a page that imports the definition as a value ships it — the upstream host, the middleware, every endpoint path. The routes carry the API's type and each endpoint's transport, which is all the caller needs.
ProxyCallerOptions
| Option | Type | Default | Description |
|---|---|---|---|
proxyPath | ProxyPath | per-transport defaults | Per-transport proxy endpoint paths (http / sse / ws) |
timeout | number | — | Request timeout in ms |
cancelPrevious | readonly string[] | the API's own | The reads a newer call cancels while in flight |
coalesce | boolean | the API's own, or true | Whether identical reads in flight share one request |
credentials | RequestCredentials | "include" | Whether the browser sends its cookies to the proxy route; the proxy forwards them where the source or endpoint names cookie |
headers | Record<string, string> | — | Default headers |
onError | (error: HTTPError) => void | — | Global error callback |
onSuccess | (data: unknown) => void | — | Global success callback |
ProxyPath shape:
| Field | Default | Notes |
|---|---|---|
proxyPath.http | "/api/request" | HTTP proxy endpoint |
proxyPath.sse | "/api/sse" | SSE proxy endpoint |
proxyPath.ws | none — required for WS endpoints | Use absolute wss:// URL for Next.js / serverless. See standalone server. |
The Dot-Notation Interface
Both factories return a typed client with dot-notation access:
const caller = createCaller(api)
// Access endpoints via group.method notation
const users = await caller.users.getAll()
const user = await caller.users.getById({ id: "1" })Call Options
Per-call options are named with .with(), which returns a caller carrying them:
// Stream mode — the body arrives as a ReadableStream instead of parsed
const response = await caller.events.with({ stream: true }).subscribe({ channel: "builds" })
// A read that may be cached goes to the proxy as GET; POST keeps this call's variables out of the address
const users = await caller.users.with({ method: "POST" }).getAll({ page: 1 })
// Upload progress tracking
await caller.files
.with({
onUploadProgress: ({ percent }) => console.log(`${Math.round(percent * 100)}%`),
})
.upload(formData)
// Abort signal
const controller = new AbortController()
await caller.users.with({ signal: controller.signal }).getAll()Using Both Modes Together
Most apps use both modes — proxy for the browser, direct for the server. Keep them in separate modules: the server's caller holds the API definition, and a browser module that imported it would ship the definition with the page.
import { createCaller } from "@noverax/courier"
import { api } from "@/api/sources"
// Server-side (RSC, Server Actions, API routes)
export const serverCaller = createCaller(api)import { createProxyCaller } from "@noverax/courier"
import { apiRoutes } from "@/courier.routes"
// Client-side (React components): the routes Courier writes, never the API itself
export const clientCaller = createProxyCaller(apiRoutes)See Exposing Endpoints for what the routes name and why they keep the definition out of the bundle.
Next Steps
- Framework Guides — complete setup for your framework
- React Query — hooks with full type inference
- Error Handling — handling HTTPError