Skip to content

Core ConceptsHTTP Client & Methods

HTTP Client & Methods

How Courier wraps native fetch with typed methods, automatic serialization, and path parameter extraction.

3 min readboth modes

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.

api/users.ts
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.
typescript
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:

api/comments.ts
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:

typescript
// 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:

api/sources.ts
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