Skip to content

IntroductionArchitecture

Architecture

How Courier works under the hood — request flow, transport modes, middleware chain, and framework adapters.

3 min readboth modes

Courier is built around a simple idea: define your API once, call it from anywhere. The architecture has four layers — definition, transport, execution, and integration — that work together across any JavaScript framework.

The Four Layers

Architecture layers
// Layer 1 — Definition (shared across all frameworks)
const api = defineApi({ baseURL: "...", groups: { users } })
export { api }

// Layer 2 — Transport (mode-specific)
// Direct: createCaller(api) → executeRequest → fetch → backend
// Proxy:  createProxyCaller<typeof api>("api") → GET or POST /api/request → adapter → executeRequest → fetch → backend

// Layer 3 — Execution (shared core engine)
// executeRequest → middleware chain → schema validation → HttpClient → fetch

// Layer 4 — Integration (optional)
// createProxyQueryCaller wraps createProxyCaller with React Query hooks
  1. Definition — courier, defineGroup, defineApi define your API structure, types, and validation. Shared between all modes and frameworks.
  2. Transport — determines how the request reaches the backend. Direct calls it immediately; proxy goes through your server.
  3. Execution — executeRequest is the single orchestrator that runs middleware, validates schemas, and delegates to the HTTP client.
  4. Integration — optional wrappers like createProxyQueryCaller that add React Query hooks on top.

Request Flow

Direct Mode

Server-side code calls the backend with no proxy hop:

Direct mode
┌──────────────────────────────────────────────────────────────┐
│  Any Runtime (Node.js, Bun, Deno, RSC, Server Actions)       │
│                                                              │
│  createCaller(api)                                           │
│       │                                                      │
│       ▼                                                      │
│  executeRequest(sources, payload, context)                   │
│       │  → middleware chain                                  │
│       │  → schema validation                                 │
│       ▼                                                      │
│  HttpClient → native fetch → GET/POST/PUT/PATCH/DELETE       │
└──────────────────┬───────────────────────────────────────────┘
                   │
                   ▼
┌──────────────────────────────────────────────────────────────┐
│  Your Backend (Express, FastAPI, Rails, Go, Laravel...)      │
└──────────────────────────────────────────────────────────────┘

Proxy Mode

The browser sends requests through your server, which hides API keys and bypasses CORS:

Proxy mode
┌──────────────────────────────────────────────────────────────┐
│  Browser                                                     │
│                                                              │
│  createProxyCaller<typeof api>("api")                        │
│       │                                                      │
│       ▼                                                      │
│  fetch("/api/request?_s=api&_g=users&_m=create", {           │
│    method: "POST",                                           │
│    body: { variables, options }                              │
│  })                                                          │
└──────────────────┬───────────────────────────────────────────┘
                   │  POST /api/request (a read goes as GET, its variables in _v)
                   ▼
┌──────────────────────────────────────────────────────────────┐
│  Your Server (Next.js, Nuxt, Hono, Express, Fastify...)      │
│                                                              │
│  Server Adapter → createHandler({ sources })                 │
│       │  → origin validation                                 │
│       │  → cookies only where named                          │
│       ▼                                                      │
│  executeRequest(sources, payload, context)                   │
│       │  → middleware chain                                  │
│       │  → schema validation                                 │
│       ▼                                                      │
│  HttpClient → native fetch → GET/POST/PUT/PATCH/DELETE       │
└──────────────────┬───────────────────────────────────────────┘
                   │
                   ▼
┌──────────────────────────────────────────────────────────────┐
│  Your Backend (any REST API)                                 │
└──────────────────────────────────────────────────────────────┘

Security built in

Proxy mode validates the Origin header, whitelists forwarded headers, and keeps your backend URL and API keys server-side. The browser only sees /api/request.

Core Components

defineApi / defineGroup / endpoint

The functional API for defining your sources. No classes, no inheritance — just plain functions that produce typed objects:

api/sources.ts
import { courier, defineApi, defineGroup } from "@noverax/courier/define"

const { endpoint } = courier()

type User = { id: string; name: string; email: string }

const users = defineGroup({
  prefix: "users",
  endpoints: {
    getAll: endpoint.get("").response<User[]>(),
    getById: endpoint.get(":id").variables<{ id: string }>().response<User>(),
    create: endpoint.post("").variables<{ name: string; email: string }>().response<User>(),
  },
})

const api = defineApi({
  baseURL: "https://api.example.com",
  groups: { users },
})

export { api }

executeRequest

The core engine. Both modes converge here. It resolves the source, group, and method, runs the middleware chain, validates schemas, and delegates to the HTTP client:

Core orchestration
// executeRequest is the single core orchestrator
executeRequest(sources, {
  source: "api",
  group: "users",
  method: "getById",
  variables: { id: "5" },
}, context)

// Internally:
// 1. Look up sources["api"] → api definition
// 2. Find the group: api.groups["users"]
// 3. Run middleware chain (retry, auth, logging...)
// 4. Validate variables against schema (if configured)
// 5. Create HttpClient with baseURL + prefix
// 6. Call the endpoint: users.getById(http, vars)
// 7. Validate response against schema (if configured)
// 8. Return the typed response

HttpClient

A thin wrapper around native fetch. Zero dependencies. Handles URL params, body encoding, search params, and abort controllers:

HttpClient
// HttpClient generates typed HTTP methods from baseURL + prefix
// Each method resolves :param placeholders from variables:
//   get(":id", { id: "5" }) → GET /users/5
//   post("", vars) → POST /users with JSON body

createHandler

The universal adapter interface. All server framework adapters use it internally:

Adapter architecture
// Core provides createHandler() → { handle, handleGet, handlePost }
// Sources come from the core global registry populated by
// `registerSources()` or `attachRuntime()`. Each adapter wraps
// `createHandler()` for its framework:

// Next.js:  apiRoute() → { GET, POST }
// Hono:     courierHandler() → (c) => Response
// Express:  courierHandler() → (req, res) => void
// Fastify:  courierPlugin → FastifyPluginAsync
// NestJS:   courierMiddleware() → (req, res, next) => void
// Nuxt:     courierHandler() → H3EventHandler

Factory Functions

FactoryModeEnvironmentReturns
createCaller(api)DirectAny runtimeTypedClient<typeof api>
createProxyCaller<typeof api>("api")ProxyBrowserTypedClient<typeof api>
createQueryCaller(api)Direct + hooksBrowser (React)QueryCallerClient<typeof api>
createProxyQueryCaller<typeof api>("api", { boundary })Proxy + hooksBrowser (React)QueryCallerClient<typeof api>

The callers return a TypedClient<typeof api>, and the query callers the same groups with React Query hooks on each — q.users.useQuery("getAll") — so type inference is identical whichever factory you use. Build each once in a module of its own: see Singleton Pattern.

Next Steps