Skip to content

Core ConceptsCaller & Proxy

Caller & Proxy

Two transport modes for calling your API — direct and proxy — with the same type-safe interface.

3 min readboth modes

Overview

Courier provides two ways to call your API endpoints. Both share the same TypedClient interface and the same type inference:

FactoryModeUse Case
createCaller(api)DirectServer-side, Node.js, Bun, Deno, scripts
createProxyCaller<typeof api>("api")ProxyBrowser → your server → backend

createCaller (Direct Mode) direct mode

Calls your backend directly. Works in any JavaScript runtime with fetch.

lib/caller.ts
import { createCaller } from "@noverax/courier"
import { api } from "@/api/sources"

export const caller = createCaller(api)
ts
// Works anywhere: Node.js, Bun, Deno, Server Components, scripts
const users = await caller.users.getAll()
//    ^? User[]

const user = await caller.users.getById({ id: "1" })
//    ^? User

When 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.

lib/caller.ts
import { createProxyCaller } from "@noverax/courier"
import type { api } from "@/api/sources"
import { boundary } from "@/api/boundary"

export const caller = createProxyCaller<typeof api>("api", {
  boundary,
  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),
})
ts
// Browser sends GET /api/request → your server → backend
const users = await caller.users.getAll()

Source object passed in

Pass the actual api object to createProxyCaller (not just typeof api). The caller reads api.options.proxyPath as source-level defaults and uses the embedded endpoint metadata for SSE/WS transport detection.

ProxyCallerDefaults

OptionTypeDefaultDescription
proxyPathProxyPathper-transport defaultsPer-transport proxy endpoint paths (http / sse / ws)
sourceobject—The defineApi() result. Required for SSE/WS detection
timeoutnumber—Request timeout in ms
credentialsRequestCredentials"include"Whether the browser sends its cookies to the proxy route; the proxy forwards them where the source or endpoint names cookie
headersRecord<string, string>—Default headers
onError(error: HTTPError) => void—Global error callback
onSuccess(data: unknown) => void—Global success callback

ProxyPath shape:

FieldDefaultNotes
proxyPath.http"/api/request"HTTP proxy endpoint
proxyPath.sse"/api/sse"SSE proxy endpoint
proxyPath.wsnone — required for WS endpointsUse absolute wss:// URL for Next.js / serverless. See standalone server.

The Dot-Notation Interface

Both factories return a typed client with dot-notation access:

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

ts
// Stream mode — the body arrives as a ReadableStream instead of parsed
const response = await caller.events.with({ stream: true }).subscribe({ channel: "builds" })

// A read 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.

lib/server-caller.ts
import { createCaller } from "@noverax/courier"
import { api } from "@/api/sources"

// Server-side (RSC, Server Actions, API routes)
export const serverCaller = createCaller(api)
lib/client-caller.ts
import { createProxyCaller } from "@noverax/courier"
import type { api } from "@/api/sources"
import { boundary } from "@/api/boundary"

// Client-side (React components): the API as a type, the boundary as the value
export const clientCaller = createProxyCaller<typeof api>("api", { boundary })

See defineBoundary for what the boundary names and why the API is imported as a type.

Next Steps