Overview
Courier uses a middleware chain to intercept requests at any stage, a composable pattern inspired by Koa and Express. Each middleware wraps around the next one, forming an onion-like execution model, and works in one of three phases:
beforethe call — modify headers, variables, or context.wrap— callnext()to execute the next middleware (or the endpoint handler), with code on both sides of it.afterthe call — inspect or transform the response.
Defining middleware
defineMiddleware(name, phase, handler) creates a typed middleware. The name is what Studio shows for it; a wrap handler receives a context object and a next function:
import { defineMiddleware } from "@noverax/courier/middleware"
const logger = defineMiddleware("logger", "wrap", async (ctx, next) => {
const label = [ctx.source, ctx.group, ctx.method].filter(Boolean).join(":")
const start = performance.now()
try {
const response = await next()
const ms = Math.round(performance.now() - start)
console.log(`${label} → ${response.status} (${ms}ms)`)
return response
} catch (error) {
const ms = Math.round(performance.now() - start)
console.error(`${label} → ERROR (${ms}ms)`)
throw error
}
})Middleware context
The ctx object provides information about the current request. A before handler, or a wrap handler before it calls next(), can read or modify it:
type MiddlewareContext = {
source: string // Source name (e.g., "api")
group?: string // Group name (e.g., "users")
method: string // Endpoint method name (e.g., "getAll")
variables?: unknown // Request variables — can be modified
headers: Headers // Request headers — can be modified
transport?: "http" | "sse" | "ws" // What kind of call it is
request?: Request // The incoming request, when a proxy runs the call
kind?: "query" | "mutation" // Whether the endpoint reads or writes
carriesSession?: boolean // Whether the call carries the user's session
sessionPartition?: string // Tells one user's calls from the next in a browser or an app
signal?: AbortSignal // Fires when the caller stops waiting
purpose?: "csrf-token" // A request the session makes, not an endpoint's call
agent?: AgentCall // The agent the call comes from, when an MCP server made it
}
type MiddlewareResponse = {
status: number
data: unknown
headers: Headers
}
type NextFn = () => Promise<MiddlewareResponse>
type BeforeHandler = (ctx: MiddlewareContext) => void | Promise<void>
type AfterHandler = (
ctx: MiddlewareContext,
response: MiddlewareResponse,
) => MiddlewareResponse | Promise<MiddlewareResponse>
type WrapHandler = (ctx: MiddlewareContext, next: NextFn) => Promise<MiddlewareResponse>Applying middleware
Pass middleware as an array to defineApi. They execute in array order — the first middleware is the outermost (runs first on request, last on response):
import { defineApi } from "@noverax/courier/define"
import { logMiddleware, authMiddleware } from "@noverax/courier/middleware"
const api = defineApi({
baseURL: process.env.API_URL ?? "http://localhost:4000",
middleware: [
logMiddleware(),
authMiddleware({ getToken: () => getAuthToken() }),
],
groups: { users, posts },
})Built-in middleware
Courier ships with five built-in middleware functions ready to use:
logMiddleware
Logs every request with endpoint name, status code, and duration:
import { logMiddleware } from "@noverax/courier/middleware"
logMiddleware() // Courier's logger: answers at info, 5xx and failures at warn
logMiddleware({ logger: (msg, error) => myLogger(msg, error) }) // Custom logger; `error` is set for a failed requestauthMiddleware
Attaches an auth token to every request. Optionally refreshes the token on 401:
import { authMiddleware } from "@noverax/courier/middleware"
authMiddleware({
getToken: () => localStorage.getItem("token")!,
refreshToken: () => refreshAccessToken(), // Optional: renew and resend once on 401
header: "Authorization", // Default
scheme: "Bearer", // Default
})retryMiddleware
Retries failed requests with exponential backoff:
import { retryMiddleware } from "@noverax/courier/middleware"
retryMiddleware({
limit: 3, // Max retries (default: 3)
delay: 300, // First wait in ms (default: 300)
backoff: "exponential", // How the wait grows (default: "exponential")
retryOn: [0, 502, 503, 504], // Statuses or a test (default: 0 and >= 500)
})rateLimitMiddleware
Delays requests to stay within a rate limit window:
import { rateLimitMiddleware } from "@noverax/courier/middleware"
rateLimitMiddleware({
maxRequests: 10, // Max requests in the window
window: 1000, // Time window in ms
})cacheMiddleware
Caches successful responses with a configurable TTL:
import { cacheMiddleware } from "@noverax/courier/middleware"
cacheMiddleware({
scope: "public", // Or (ctx) => a key naming the caller
ttl: 30_000, // Time-to-live in ms (default: 60000)
storage: map, // Optional custom Map storage
})Composing middleware
Use composeMiddleware to combine multiple middleware into a single wrap handler, and defineMiddleware to make that one middleware. This is useful when you want to create reusable middleware stacks:
import { defineApi } from "@noverax/courier/define"
import {
authMiddleware,
composeMiddleware,
defineMiddleware,
logMiddleware,
retryMiddleware,
} from "@noverax/courier/middleware"
// Combine into a single middleware
const defaultStack = defineMiddleware(
"default-stack",
"wrap",
composeMiddleware([
logMiddleware(),
authMiddleware({ getToken: () => getAuthToken() }),
retryMiddleware({ limit: 2 }),
]),
)
// Use as a single entry
const api = defineApi({
baseURL: "https://api.example.com",
middleware: [defaultStack],
groups: { users },
})Custom middleware examples
Auth token injection
import { defineMiddleware } from "@noverax/courier/middleware"
const auth = defineMiddleware("auth", "before", async (ctx) => {
const token = await getAuthToken()
ctx.headers.set("Authorization", `Bearer ${token}`)
})Response transformation
Unwrap API envelopes so your components receive clean data:
import { defineMiddleware } from "@noverax/courier/middleware"
const unwrapEnvelope = defineMiddleware("unwrap-envelope", "after", (_ctx, response) => {
// Many APIs wrap responses in { data: ..., meta: ... }
const body = response.data
if (body && typeof body === "object" && "data" in body) {
return { ...response, data: body.data }
}
return response
})Error reporting
import * as Sentry from "@sentry/nextjs"
import { defineMiddleware } from "@noverax/courier/middleware"
const sentryMiddleware = defineMiddleware("sentry", "wrap", async (ctx, next) => {
try {
return await next()
} catch (error) {
Sentry.captureException(error, {
tags: { source: ctx.source, group: ctx.group, method: ctx.method },
extra: { variables: ctx.variables },
})
throw error
}
})Full logging example
Combine built-in and custom middleware for a complete request logging pipeline:
import { defineApi } from "@noverax/courier/define"
import { logMiddleware, authMiddleware, retryMiddleware } from "@noverax/courier/middleware"
const api = defineApi({
baseURL: process.env.API_URL ?? "http://localhost:4000",
middleware: [
logMiddleware(),
authMiddleware({ getToken: () => getAuthToken() }),
retryMiddleware({ limit: 3, delay: 500 }),
],
groups: { users, posts },
})Handlers may be async
A before or after handler may return a promise, and a wrap handler always does: Courier awaits it before the chain goes on.
Next steps
- Options — full reference of source-level and per-request options
- Abort & Deduplication — automatic request deduplication with abort signals
- Streaming — receive responses as ReadableStreams for SSE and large downloads