Overview
createProxyCaller creates a typed client with dot-notation access that calls your server's proxy endpoint: a read as GET, a write as POST. It is the primary way to make requests from client components in proxy mode.
import { createProxyCaller } from "@noverax/courier"
import type { api } from "@/api"
// createProxyCaller<T>(source, defaults?) returns TypedClient<T>.
// `source` is the key this API has in your `sources` map — the proxy
// addresses it by that name. Importing `api` as a *type* keeps the
// definition out of the browser bundle.
const caller = createProxyCaller<typeof api>("api")
// Dot-notation usage:
const result = await caller.users.getById({ id: 5 })
// ^? group ^? endpoint ^? variablesThe first argument names the source; the generic parameter typeof api provides full type inference for group names, endpoint names, variables, and response types.
Defaults
Pass a ProxyCallerDefaults object to configure global behavior for all requests created by this factory:
type ProxyPath = {
// HTTP proxy endpoint — default: "/api/request"
http?: string
// SSE proxy endpoint — default: "/api/sse"
sse?: string
// WS proxy endpoint — NO default. Required for WS endpoints; use absolute
// wss:// URL on Next.js / serverless platforms.
ws?: string
}
type ProxyCallerDefaults = {
// Per-transport proxy endpoint paths. Caller-level fields are merged
// per-transport over `source.options.proxyPath` (caller wins).
proxyPath?: ProxyPath
// The defineApi() result. Required for SSE/WS transport detection.
source?: object
// Request timeout in milliseconds
timeout?: number
// Cookie behavior — default: "include"
credentials?: RequestCredentials
// Extra headers sent with every proxy request
headers?: Record<string, string>
// Called on every failed request
onError?: (error: HTTPError) => void | Promise<void>
// Called on every successful request
onSuccess?: (data: unknown) => void | Promise<void>
}Complete setup
A typical setup creates a single caller instance and exports it for use across all client components:
import { createProxyCaller } from "@noverax/courier"
import type { api } from "@/api"
import { boundary } from "@/api/boundary"
export const caller = createProxyCaller<typeof api>("api", {
boundary,
timeout: 30_000,
credentials: "include",
headers: {
"x-app-version": "1.0.0",
},
onError: (error) => {
if (error.status === 401) {
window.location.href = "/login"
}
},
})One instance per app
Create createProxyCaller once in a shared module. All client components import from the same file, ensuring consistent defaults and callbacks.
Dot-notation usage
Access endpoints using caller.{group}.{method}(vars?) dot notation:
// caller.groupName.endpointName(variables?)
await caller.users.getAll()
await caller.users.getById({ id: 5 })
await caller.posts.create({ title: "Hello", body: "World" })Raw data access
To access response status and headers, use the .raw namespace:
// Raw data — returns { status, headers, data }
const response = await caller.users.raw.getById({ id: "5" })Body composition
The endpoint is always named in the URL — _s for the source, _g for the group, _m for the method — so the server knows which call a request is before its body arrives. A group inside another is named by its path: client.billing.invoices.list() sends _g=billing.invoices. How the body is built depends on the type of variables you pass:
// JSON body (default for objects)
// Sends: POST /api/request?_s=api&_g=users&_m=create
// Body: { "variables": { "name": "Jane" }, "options": {} }
await caller.users.create({ name: "Jane" })
// FormData body (when variables is FormData)
// Sends: POST /api/request?_s=api&_g=users&_m=create (multipart/form-data)
// Body: _options={...}, name=Jane, avatar=File
const formData = new FormData()
formData.append("name", "Jane")
formData.append("avatar", fileInput.files[0])
await caller.users.create(formData)The _options field
When sending FormData, Courier writes the call's options as an _options field before your own fields. The server handler reads that first _options field and passes everything else to your endpoint, so a field of your own named _method, _source or even _options reaches your backend unchanged — a Laravel or Rails form's _method=PUT included.
Binary variables
JSON cannot carry a Blob, an ArrayBuffer or a typed array, so a proxy call whose plain-object variables hold one is refused with a ConfigurationError (PROXY_VARIABLE_NOT_SERIALIZABLE) before anything is sent. Put binary data in a FormData.
Binary answers
A source whose responseType is "arrayBuffer", "blob" or "formData" resolves to the same kind of value through the proxy as in direct mode. The proxy sends the bytes as they are, under a content type of its own — application/vnd.courier.bytes or application/vnd.courier.blob, with nosniff — so a browser never renders them as a page of your origin, and the client reads them back; a Blob keeps the upstream's type. The content type is the one header a page reads on a cross-origin answer without it being exposed, so this works for a proxy on another origin too. With .raw, the headers are those of the proxy's answer.
Per-request options
.with() returns a caller carrying options for the calls made through it, overriding the defaults for a specific request:
const user = await caller.users
.with({
timeout: 5000,
signal: abortController.signal,
cache: "no-store",
})
.getById({ id: 5 })Global callbacks
The onError and onSuccess callbacks run for every request made by this factory. Use them for cross-cutting concerns like authentication redirects or logging:
const caller = createProxyCaller<typeof api>("api", {
onSuccess: (data) => {
// Called after every successful response
console.log("Response:", data)
},
onError: (error) => {
// Called after every failed response
// error.status — HTTP status code
// error.data — parsed response body
// error.headers — response headers
if (error.status === 401) {
window.location.href = "/login"
}
if (error.status === 429) {
toast.error("Too many requests. Please wait.")
}
},
})Custom proxy endpoint
By default, HTTP requests are sent to /api/request. Override it via proxyPath.http:
export const caller = createProxyCaller<typeof api>("api", {
boundary,
proxyPath: { http: "/api/my-proxy" },
})Match your server handler
If you change the proxy URL, make sure your server handler is mounted at the same path. For example, proxyPath: { http: "/api/my-proxy" } requires the server handler at /api/my-proxy.
Realtime: SSE and WS proxy paths
proxyPath carries per-transport configuration. SSE has a sensible default; WS does not — you must point it at a WebSocket-capable server explicitly.
export const caller = createProxyCaller<typeof api>("api", {
boundary,
proxyPath: {
http: "/api/request",
sse: "/api/sse",
// Required for WS endpoints. On Next.js / Cloudflare / Vercel Edge,
// run @noverax/courier-server separately and point this at it.
ws: "wss://realtime.example.com",
},
})WS has no built-in default
Calling a WS endpoint without proxyPath.ws throws ConfigurationError("WS_PATH_NOT_CONFIGURED"). See standalone server deployment for the runtime matrix.
Per-transport merge precedence
You can declare proxyPath once on defineApi, then override individual transports per-environment:
export const api = defineApi({
baseURL: "https://api.example.com",
proxyPath: {
http: "/api/request",
sse: "/api/sse",
ws: "wss://realtime.example.com", // prod
},
groups: { /* ... */ },
})// Override only the WS endpoint for local dev — http and sse stay as
// defined on the source.
export const caller = createProxyCaller<typeof api>("api", {
boundary,
proxyPath: { ws: "ws://localhost:3001" },
})Next Steps
- Server Handler — set up the server-side endpoint
- File Upload — upload files with progress tracking
- Proxy Mode Overview — architecture and security model