Skip to content

Proxy ModecreateProxyCaller

createProxyCaller

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

5 min readUpdated proxy mode

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.

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

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

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

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

Usage examples
// 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 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. 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 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
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:

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

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

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

api/sources.ts (server)
export const api = defineApi({
  baseURL: "https://api.example.com",
  proxyPath: {
    http: "/api/request",
    sse: "/api/sse",
    ws: "ws://localhost:3001",
  },
  groups: { /* ... */ },
})

Next Steps