Overview
Under the hood, Courier uses an HttpClient class that wraps the native fetch API — no external HTTP libraries. You never create an HttpClient directly — it is created internally when a request is executed. What you interact with are the typed HTTP methods that HttpClient provides to your endpoint definitions via the http object.
HTTP Methods
An endpoint names its HTTP method with a head — endpoint.get(path), .post, .put, .patch, .del, .head, .options or .query. One whose path or body its variables have to be worked into passes .via() a closure instead, which receives an http object with the same helpers.
import { courier, defineGroup } from "@noverax/courier/define"
const { endpoint } = courier()
type User = { id: string; name: string; email: string }
type CreateUserVars = { name: string; email: string }
type UpdateUserVars = { id: string; name: string; email: string }
export const users = defineGroup({
prefix: "users",
endpoints: {
// GET /users — variables become search params
getAll: endpoint.get("").variables<{ page?: number; limit?: number }>().response<User[]>(),
// GET /users/:id — :id is extracted from variables
getById: endpoint.get(":id").variables<{ id: string }>().response<User>(),
// POST /users — variables become JSON body
create: endpoint.post("").variables<CreateUserVars>().response<User>(),
// PUT /users/:id — :id extracted, rest becomes JSON body
update: endpoint.put(":id").variables<UpdateUserVars>().response<User>(),
// PATCH /users/:id — partial update
patch: endpoint.patch(":id").variables<{ id: string; email: string }>().response<User>(),
// DELETE /users/:id
remove: endpoint.del(":id").variables<{ id: string }>().response<null>(),
},
})Method Signatures
The key difference between methods is how they handle variables:
- GET, DELETE, HEAD — variables are automatically encoded as search params (
?page=1&limit=10). - POST, PUT, PATCH — variables are automatically sent as a JSON body.
import { courier } from "@noverax/courier/define"
const { endpoint } = courier()
// GET, DELETE, HEAD — variables sent as search params
const search = endpoint.get("search").variables<{ page: number; limit: number }>()
// → GET /users/search?page=1&limit=10
// POST, PUT, PATCH — variables sent as JSON body
const create = endpoint.post("").variables<{ name: string; email: string }>()
// → POST /users body: { name: "Jane", email: "jane@test.com" }
// A closure, for a path the variables are worked into
const archive = endpoint
.variables<{ year: number; ids: string[] }>()
.via((http, vars) => http.post(`archive/${vars.year}`, { ids: vars.ids }))
// → POST /users/archive/2025 body: { ids: [...] }Automatic serialization
You never need to call JSON.stringify or build query strings manually. Courier handles encoding based on the HTTP method. You can customize this with bodySerializer and searchParamsSerializer in defineApi options.
Path Parameters
Use the :param syntax in URLs to define path parameters. Courier extracts matching keys from variables, inserts them into the URL, and sends the remaining variables as body or search params:
import { courier, defineGroup } from "@noverax/courier/define"
const { endpoint } = courier()
type Comment = { id: string; body: string; author: string }
export const comments = defineGroup({
prefix: "posts",
endpoints: {
// Multiple path params: /posts/:postId/comments/:commentId
getOne: endpoint.get(":postId/comments/:commentId").variables<{ postId: string; commentId: string }>().response<Comment>(),
// Path params + body: :postId is extracted, rest becomes body
create: endpoint.post(":postId/comments").variables<{ postId: string; body: string }>().response<Comment>(),
},
})
// Usage:
// caller.comments.getOne({ postId: "5", commentId: "12" })
// → GET /posts/5/comments/12
// caller.comments.create({ postId: "5", body: "Great post!" })
// → POST /posts/5/comments body: { body: "Great post!" }Internal regex parser
Path parameter extraction uses an internal regex-based :param parser. Any pattern like :id, :slug, or :postId works out of the box. The parser is cached for performance.
Response Type
Every HTTP method returns an HttpResponse<T> internally. By default, Courier unwraps this and returns just the data property. If you need the full response (status code, headers), call through the group's .raw accessor:
// HttpResponse<T> — what every method returns internally
type HttpResponse<T> = {
status: number // HTTP status code (200, 201, 404, ...)
headers: Headers // Response headers
data: T // Parsed response body, typed as T
}
// By default, Courier returns just the data (unwrapped)
const users = await caller.users.getAll()
// ^? User[]
// Through .raw, you get the full response object
const response = await caller.users.raw.getAll()
// ^? { status: number; headers: Headers; data: User[] }Configuring the HTTP Client
The defineApi function accepts BaseOptions — a comprehensive set of options that configure the underlying HttpClient. These options apply to every endpoint in every group under this source:
import { defineApi } from "@noverax/courier/define"
import {
retryMiddleware,
authMiddleware,
logMiddleware,
} from "@noverax/courier/middleware"
import { users } from "./users"
import { posts } from "./posts"
const api = defineApi({
// Required: your backend URL
baseURL: process.env.API_URL!,
// Response format (default: "json")
responseType: "json",
// Default data merged into every POST/PUT/PATCH body
defaultBody: { locale: "en" },
// Default params merged into every GET request
defaultQueryParams: { format: "full" },
// Timeout in milliseconds
timeout: 30_000,
// Default headers for all requests
headers: { "X-Client": "courier" },
// Cancel duplicate in-flight requests (default: false)
deduplicate: true,
// Middleware chain — retry, auth, logging, etc.
middleware: [
logMiddleware(),
retryMiddleware({ limit: 2 }),
authMiddleware({ getToken: () => getAuthToken() }),
],
// Groups
groups: { users, posts },
})defaultBody and defaultQueryParams
defaultBody is merged into every POST/PUT/PATCH body, and defaultQueryParams is merged into every GET request. Use these for values like locale, API version, or tenant ID that should be sent with every request.
Next Steps
- Request Lifecycle — how requests flow through callers, middleware, and fetch
- Type Safety — how types flow from method definitions to your components
- Error Handling — handling HTTP errors and the HTTPError class