Skip to content

Proxy ModecreateProxyCaller

createProxyCaller

The browser-side factory that sends typed requests through your server proxy endpoint.

4 min readproxy mode

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.

lib/caller.ts
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  ^? variables

The 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:

typescript
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:

lib/caller.ts
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:

Usage examples
// 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 calls
// 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 vs FormData
// 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:

Per-request options
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:

Global error handling
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:

lib/caller.ts
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.

lib/caller.ts
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:

api/sources.ts (server)
export const api = defineApi({
  baseURL: "https://api.example.com",
  proxyPath: {
    http: "/api/request",
    sse: "/api/sse",
    ws: "wss://realtime.example.com",  // prod
  },
  groups: { /* ... */ },
})
lib/caller.dev.ts (browser)
// 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