The pattern
Every Courier factory learns your API's type in one place: from the API object you pass to createCaller or createQueryCaller, or from the type argument of createProxyCaller and createProxyQueryCaller. Build each client once, in a module of its own, and export it. Components, loaders and server code import that client, so none of them names a type argument or repeats a default, and a change to the API reaches every call site through that one module.
// Without it, every file builds its own client and names the type again:
import { createProxyCaller } from "@noverax/courier"
const users = await createProxyCaller<typeof api>("api", { boundary }).users.getAll()// With it, every file imports the one client:
import { caller } from "@/lib/caller"
const users = await caller.users.getAll()Step-by-step setup
Step 1: Define your API
Create your groups and API definition with courier, defineGroup and defineApi:
import { courier, defineGroup } from "@noverax/courier/define"
const { endpoint } = courier()
export type User = { id: number; name: string; email: string }
export const users = defineGroup({
prefix: "users",
endpoints: {
getAll: endpoint.get("").response<User[]>(),
getById: endpoint.get(":id").variables<{ id: number }>().response<User>(),
create: endpoint.post("").variables<{ name: string; email: string }>().response<User>(),
},
})import { defineApi } from "@noverax/courier/define"
import { users } from "./users"
export const api = defineApi({
name: "api",
baseURL: process.env.API_URL ?? "http://localhost:4000",
groups: { users },
})Step 2: Build each client once
Give each client a module that builds it and exports it. In the browser, import the API as a type and pass a boundary: the type argument carries every group, endpoint, variable and response type, and the boundary carries the transports, so the definition itself — its upstream host, its middleware, every endpoint path — stays out of the bundle.
import { defineBoundary } from "@noverax/courier/define"
export const boundary = defineBoundary({
source: "api",
expose: { "users.getAll": "query", "users.getById": "query", "users.create": "mutation" },
})import { createProxyCaller } from "@noverax/courier"
import type { api } from "@/api"
import { boundary } from "@/api/boundary"
export const caller = createProxyCaller<typeof api>("api", { boundary })import { createProxyQueryCaller } from "@noverax/courier-react"
import type { api } from "@/api"
import { boundary } from "@/api/boundary"
export const q = createProxyQueryCaller<typeof api>("api", { boundary })The boundary also belongs on the API itself, so the server refuses what it does not name: see Wiring the server.
On the server, where the definition is already present, pass the API object and the type comes with it:
import { createCaller } from "@noverax/courier"
import { api } from "@/api"
export const serverCaller = createCaller(api, { timeout: 10_000 })Step 3: Import the client
Every call site imports the client and gets full autocomplete for group names, method names and variable types:
import { serverCaller } from "@/lib/server-caller"
export default async function UserPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const user = await serverCaller.users.getById({ id: Number(id) })
// ^? User
return <h1>{user.name}</h1>
}Using with React Query
The pattern pays off most with createProxyQueryCaller, whose hooks are used throughout an app. Each component imports q and nothing else:
"use client"
import { q } from "@/lib/query"
export function UserList() {
// "users" is a known group, "getAll" a known method, and data is User[]
const { data } = q.users.useQuery("getAll")
const { mutate } = q.users.useMutation("create", {
onSuccess: (user) => {
// user is typed as User
console.log(user.name)
},
})
return (
<div>
{data?.map((user) => <p key={user.id}>{user.name}</p>)}
<button onClick={() => mutate({ name: "Jane", email: "jane@test.com" })}>
Add User
</button>
</div>
)
}Multiple sources
If your app connects to several backends, build one client per source, each named after it:
import { createProxyQueryCaller } from "@noverax/courier-react"
import type { mainApi, paymentApi } from "@/api"
import { mainBoundary, paymentBoundary } from "@/api/boundary"
export const mainQ = createProxyQueryCaller<typeof mainApi>("mainApi", { boundary: mainBoundary })
export const paymentQ = createProxyQueryCaller<typeof paymentApi>("paymentApi", {
boundary: paymentBoundary,
})
// mainQ.users.useQuery("getAll") ← knows mainApi's groups
// paymentQ.products.useQuery("list") ← knows paymentApi's groupsOne module, not a global declaration
Courier keeps no global registry of API types, and a factory never looks one up: the type comes from the API object or the type argument. The module that builds a client is therefore the single place its type is named. When you add a group or a method, every q.{group}.useQuery() and caller.{group}.{method}() that imports the client sees it at once.
Next steps
- Project Setup — recommended file structure, with the client modules in
lib/ - defineBoundary — what the browser may call, and why it keeps the definition out of the bundle
- Options — source-level and per-request configuration