Skip to content

AdvancedSingleton Pattern

Singleton Pattern

Build each client once, in a module of its own, and import it wherever a call is made.

3 min readboth modes

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.

ts
// 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()
ts
// 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:

api/users.ts
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>(),
  },
})
api/index.ts
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.

api/boundary.ts
import { defineBoundary } from "@noverax/courier/define"

export const boundary = defineBoundary({
  source: "api",
  expose: { "users.getAll": "query", "users.getById": "query", "users.create": "mutation" },
})
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 })
lib/query.ts
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:

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

app/users/[id]/page.tsx
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:

components/user-list.tsx
"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:

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

One 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