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 readUpdated both 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(apiRoutes)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. The caller takes the routes Courier writes into courier.routes.ts from the endpoints you marked .expose(): what a page may call, and how it reaches each — see Exposing Endpoints.

lib/caller.ts
import { createProxyCaller } from "@noverax/courier"
import { apiRoutes } from "@/courier.routes"

export const caller = createProxyCaller(apiRoutes, {
  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 POST /api/request → your server → backend
// (a read that declares .cache() goes as GET)
const users = await caller.users.getAll()

The routes, not the API

Pass the routes, never the api object: a page that imports the definition as a value ships it — the upstream host, the middleware, every endpoint path. The routes carry the API's type and each endpoint's transport, which is all the caller needs.

ProxyCallerOptions

OptionTypeDefaultDescription
proxyPathProxyPathper-transport defaultsPer-transport proxy endpoint paths (http / sse / ws)
timeoutnumber—Request timeout in ms
cancelPreviousreadonly string[]the API's ownThe reads a newer call cancels while in flight
coalescebooleanthe API's own, or trueWhether identical reads in flight share one request
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 that may be cached 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 { apiRoutes } from "@/courier.routes"

// Client-side (React components): the routes Courier writes, never the API itself
export const clientCaller = createProxyCaller(apiRoutes)

See Exposing Endpoints for what the routes name and why they keep the definition out of the bundle.

Next Steps