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 readUpdated both 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 routes you pass to 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 repeats an import or 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 repeats its options:
import { createProxyCaller } from "@noverax/courier"
import { apiRoutes } from "@/courier.routes"

const users = await createProxyCaller(apiRoutes, { timeout: 10_000 }).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, and mark what a page may call through the proxy with expose:

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",
  expose: true,
  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, pass the routes Courier writes into courier.routes.ts from your marks — see Exposing Endpoints. They carry every group, endpoint, variable and response type a page may use, and how each endpoint is reached, so the definition itself — its upstream host, its middleware, every endpoint path — stays out of the bundle.

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

export const caller = createProxyCaller(apiRoutes)
lib/query.ts
import { createProxyQueryCaller } from "@noverax/courier-react"
import { apiRoutes } from "@/courier.routes"

export const q = createProxyQueryCaller(apiRoutes)

The proxy serves only what the marks open, whichever client calls it.

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 { mainApiRoutes, paymentApiRoutes } from "@/courier.routes"

export const mainQ = createProxyQueryCaller(mainApiRoutes)
export const paymentQ = createProxyQueryCaller(paymentApiRoutes)

// 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 routes. 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/
  • Exposing Endpoints — what a page may call, and the routes that keep the definition out of the bundle
  • Options — source-level and per-request configuration