Overview
createProxyCaller creates a typed client with dot-notation access that calls your server's proxy routes. It is the way a client component calls an API in proxy mode.
It takes the routes of an API: the endpoints a page may call and how the page reaches each, which Courier writes into courier.routes.ts from the endpoints you marked .expose() — see Exposing Endpoints.
import { createProxyCaller } from "@noverax/courier"
import { apiRoutes } from "@/courier.routes"
// createProxyCaller(routes, options?) returns a client typed from the API
// the routes were written from, with the endpoints they name alone.
export const caller = createProxyCaller(apiRoutes)
// Dot-notation usage:
const result = await caller.users.getById({ id: "5" })
// ^? group ^? endpoint ^? variablesThe routes name the source as your server registered it, and how each endpoint is reached: a read that may be cached as GET, every other call as POST, a stream over server-sent events and a channel over a socket. Nothing of the API's definition comes with them, so none of it reaches the page's bundle.
Options
Pass a ProxyCallerOptions object to configure every request the client makes:
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 ProxyCallerOptions = {
// Where the app serves its proxy routes, per transport
proxyPath?: ProxyPath
// How long the browser waits for the proxy, in milliseconds
timeout?: number
// Cookie behavior — default: "include"
credentials?: RequestCredentials
// Extra headers sent with every proxy request
headers?: Record<string, string>
// The reads a newer call cancels while they are in flight — default: the API's own
cancelPrevious?: readonly string[]
// Whether identical reads in flight share one request — default: the API's own, or true
coalesce?: boolean
// Called on every failed request
onError?: (error: HTTPError) => void | Promise<void>
// Called on every successful request
onSuccess?: (data: unknown) => void | Promise<void>
// How the sockets of channels and push subscriptions behave
ws?: CallerWSOptions
}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 { apiRoutes } from "@/courier.routes"
export const caller = createProxyCaller(apiRoutes, {
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" })An endpoint the routes do not name is not on the client's type. Called anyway — from JavaScript, or past a cast — the client throws a ConfigurationError with the code ENDPOINT_NOT_IN_ROUTES before anything is sent, since the proxy would refuse it.
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. A read the routes say may be cached goes as GET, its variables as JSON in _v; every other call goes as POST, and how its 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:
import { createProxyCaller } from "@noverax/courier"
import { apiRoutes } from "@/courier.routes"
const caller = createProxyCaller(apiRoutes, {
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:
import { createProxyCaller } from "@noverax/courier"
import { apiRoutes } from "@/courier.routes"
export const caller = createProxyCaller(apiRoutes, {
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.
import { createProxyCaller } from "@noverax/courier"
import { apiRoutes } from "@/courier.routes"
export const caller = createProxyCaller(apiRoutes, {
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-environment proxy paths
The routes say nothing of where the proxy is: that depends on where the page runs, so the page says it. Read it from what your bundler gives each build — the WS server of local development, a staging host — and leave out what stays the default:
import { createProxyCaller } from "@noverax/courier"
import { apiRoutes } from "@/courier.routes"
export const caller = createProxyCaller(apiRoutes, {
proxyPath: { ws: process.env.NEXT_PUBLIC_REALTIME_URL },
})A proxyPath on defineApi is for Studio: its Playground sends a call through the proxy as a page would, at the routes it names there.
export const api = defineApi({
baseURL: "https://api.example.com",
proxyPath: {
http: "/api/request",
sse: "/api/sse",
ws: "ws://localhost:3001",
},
groups: { /* ... */ },
})Next Steps
- Exposing Endpoints — mark what a page may call, and the routes Courier writes from the marks
- Server Handler — set up the server-side endpoint
- File Upload — upload files with progress tracking
- Proxy Mode Overview — architecture and security model