Skip to content

AdvancedOptions

Options

Full reference of source-level and per-request configuration options.

5 min readboth modes

Overview

Courier options are split into layers: BaseOptions for source-level configuration (applies to all endpoints in a source), GroupOptions for a group of endpoints, and CallOptions for per-request overrides. Courier uses native fetch under the hood — no third-party HTTP clients.

BaseOptions

Set in defineApi(). These apply to every request made through this source:

types
type BaseOptions = {
  // URL & encoding
  baseURL?: string                               // Prefix URL for all requests
  responseType?: "json" | "text" | "blob" | "arrayBuffer" | "formData" // Response parsing (default: "json")
  responseParser?: (response: Response) => Promise<unknown>           // Custom response parser (overrides responseType)
  defaultBody?: Record<string, unknown>          // Merged into every request body
  defaultQueryParams?: Record<string, unknown>   // Merged into every query string
  bodySerializer?: (data: Record<string, unknown>) => BodyInit       // Custom body serializer
  searchParamsSerializer?: SearchParamsSerializer // Custom query serializer ({ stringify, parse })
  allowedHeaders?: string[]                      // Additional header names to forward

  // Behavior
  deduplicate?: boolean                          // Enable request deduplication
  timeout?: number                               // Request timeout in ms
  headers?: HeadersInit                          // Static headers for all requests
  credentials?: RequestCredentials               // Cookie handling mode
  middleware?: Middleware[]                      // Middleware chain

  // Transport — see Proxies & Certificates
  fetch?: SourceFetch                            // Sends this source's requests and opens its streams (undici's fetch)
  webSocket?: SourceWebSocket                    // Opens its channels' sockets (undici's WebSocket)
  dispatcher?: SourceDispatcher                  // undici Agent or ProxyAgent: a client certificate, an authority, a proxy
  proxy?: string                                 // Bun: the proxy its requests and sockets go through
  tls?: object                                   // Bun: the TLS they use (ca, cert, key, passphrase)
  client?: object                                // Deno: a Deno.HttpClient its requests and streams go through

  // Proxy
  expose?: boolean                               // Whether the browser may call this source through the proxy
  boundary?: ProxyBoundary                       // The endpoints a browser may call, from defineBoundary
  proxyPath?: ProxyPath                          // Where the app serves its proxy routes

  // All other fetch options (cache, mode, redirect, signal, etc.)
}

An endpoint whose .via() closure passes its own responseType has its successful answer read that way, the source's responseParser included; see Defining APIs.

GroupOptions

Set in defineGroup(). These apply to the group's endpoints and to those of the groups inside it:

types
type GroupOptions = {
  prefix?: string                  // Prepended to every endpoint path in the group
  pathOptions?: (request?: Request) => Record<string, string | number | undefined> // Path parameters filled in at request time
  expose?: boolean                 // Whether the browser may call this group through the proxy
  groups?: Record<string, DefinedGroup> // The groups inside this one

  // Transport, over the source's — see Proxies & Certificates
  fetch?: SourceFetch
  webSocket?: SourceWebSocket
  dispatcher?: SourceDispatcher    // A client certificate or a proxy for this group alone
  proxy?: string                   // Bun
  tls?: object                     // Bun
  client?: object                  // Deno
}

A group inside another takes each transport option from the nearest group that sets it; see One group.

CallOptions

Named with .with(), which returns a caller carrying them. These control how a single request behaves:

types
type CallOptions = {
  stream?: boolean               // Return ReadableStream instead of parsed response
  forwardVariables?: boolean     // Pass variables to method body (default: true)
  method?: "GET" | "POST"       // Verb sent to the proxy (default: GET for a read, POST for a write)
  signal?: AbortSignal           // Manual abort signal
  timeout?: number               // Per-request timeout override
  headers?: HeadersInit          // Per-request header overrides
  cache?: RequestCache           // Fetch cache mode
  onUploadProgress?: (progress: {
    percent: number
    transferredBytes: number
    totalBytes: number
  }) => void                     // Upload progress callback
  onDownloadProgress?: (progress: {
    percent: number
    transferredBytes: number
    totalBytes: number
  }) => void                     // Download progress callback

  // Transport for this call alone, over its group's and its source's —
  // refused through the proxy, which sends the call with the server's own
  fetch?: SourceFetch
  webSocket?: SourceWebSocket
  dispatcher?: SourceDispatcher
}

The status and headers of an answer are not an option: call through the group's .raw accessor, caller.users.raw.getAll(), and the call resolves to { status, headers, data }.

onDownloadProgress reports against the answer's content-length, which the proxy sets on every answer it re-serializes and passes on for a streamed one. When no length is known — a compressed answer — totalBytes and percent stay 0 until the last event, which reports 100% of what arrived.

Timeout & retry

Configure request timeouts at the source level and automatic retry behavior via middleware:

api/index.ts
import { defineApi } from "@noverax/courier/define"
import { retryMiddleware } from "@noverax/courier/middleware"

const api = defineApi({
  baseURL: process.env.API_URL ?? "http://localhost:4000",
  groups: { users, posts },

  // Timeout after 10 seconds
  timeout: 10_000,

  // Retry failed requests via middleware
  middleware: [
    retryMiddleware({ limit: 3, delay: 300, retryOn: [408, 502, 503, 504] }),
  ],
})

Retry via middleware

Retry logic is handled by retryMiddleware from @noverax/courier/middleware. It supports exponential backoff with a configurable base delay and custom retry conditions. See Middleware for details.

Caching

The next and cache fetch options control how the underlying fetch call is cached. They work in both direct and proxy modes.

Direct mode direct mode

Options are forwarded directly to the server-side fetch call:

app/page.tsx
import { caller } from "@/lib/caller"

// Per-request caching with Next.js ISR
const users = await caller.users.with({ next: { revalidate: 60 } }).getAll()

// Tag-based revalidation
const post = await caller.posts
  .with({ next: { tags: ["posts", `post-${id}`] } })
  .getById({ id })

// Force no caching
const live = await caller.stats.with({ cache: "no-store" }).realtime()

Proxy mode proxy mode

In proxy mode a read goes as GET, so its answer can be cached on the way back:

components/users.tsx
const users = await caller.users
  .with({
    next: { revalidate: 60 },
    cache: "force-cache",
  })
  .getAll()

The browser's own options — cache, next, credentials, headers and the rest of RequestInit — apply to the browser's request to /api/request. Combined with the Cache-Control the proxy answers with, this is what lets the browser, a CDN or the Next.js Data Cache on the page's side reuse the answer.

The proxy itself takes no caching instructions from the browser: a page could otherwise make the server cache one user's answer for everyone. How long an answer may be kept is declared on the endpoint with .cache({ revalidate }), and the proxy sets Cache-Control from it — private when the request carries a credential, shared with Vary on the forwarded headers otherwise. Any request header the proxy does not know to be impersonal counts as a credential. What the browser sends on its own counts as impersonal, and so do the headers a reverse proxy, CDN or hosting platform stamps on the way, such as X-Forwarded-For, Forwarded, Via, CF-Connecting-IP, X-Vercel-Id and traceparent. Next.js adds the X-Forwarded-* headers to every request itself.

Requires GET proxy

Proxy caching works for a call that goes as GET: a read, unless its variables are sensitive, carry a body or would make the address longer than 2 KB — see GET or POST. A write asked for over GET is refused 405.

Headers

Set static headers at the source level or override them per-request. Source-level headers apply to all requests; per-request headers are merged on top:

api/index.ts
import { defineApi } from "@noverax/courier/define"

// Static headers on the source (apply to all requests)
const api = defineApi({
  baseURL: process.env.API_URL ?? "http://localhost:4000",
  groups: { users },
  headers: {
    "X-API-Version": "2",
    "Accept-Language": "en",
  },
})

// Per-request headers
const data = await caller.users
  .with({
    headers: { "X-Custom-Header": "value" },
  })
  .getAll()

Option precedence

When the same option is set at multiple levels, per-request options take the highest priority:

api/index.ts
import { defineApi } from "@noverax/courier/define"

// Options are merged with this precedence (highest wins):
// 1. Per-request options  → caller.reports.with({ timeout: 5000 }).generate(vars)
// 2. Source-level options → defineApi({ timeout: 10000, ... })
// 3. fetch defaults       → native fetch defaults

const api = defineApi({
  baseURL: process.env.API_URL ?? "http://localhost:4000",
  groups: { users, reports },
  timeout: 10_000,  // Default for all requests in this source
})

// Override timeout for a slow endpoint
const report = await caller.reports.with({ timeout: 30_000 }).generate({ type: "annual" })

.with() can be called at several levels — the client, a group, an endpoint. The later call wins for each option it sets, and headers are merged name by name, whatever their case, so a token set on the client stays when a group adds a header of its own. Headers may be given as a record, a Headers or a list of pairs, and they reach a stream opened with .with() the same way they reach a request:

ts
await caller
  .with({ headers: { authorization: `Bearer ${token}` } })
  .reports.with({ headers: { "x-request-id": id } })
  .generate({ type: "annual" }) // sends both headers

Credentials

The credentials option controls cookie handling:

  • "include" — send cookies with cross-origin requests (default in proxy mode)
  • "same-origin" — send cookies only to same-origin requests
  • "omit" — never send cookies

Next steps

  • Middleware — composable middleware for auth, logging, retry, and more
  • Raw Data Mode — using .raw to access status codes and headers
  • Streaming — enabling stream mode for ReadableStream responses
  • Abort & Deduplication — automatic request deduplication with abort signals
  • Proxies & Certificates — a corporate proxy, an internal certificate authority, a client certificate