Skip to content

AdvancedMiddleware

Middleware

Intercept, transform, and observe every request with composable middleware functions.

3 min readboth modes

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:

  1. before the call — modify headers, variables, or context.
  2. wrap — call next() to execute the next middleware (or the endpoint handler), with code on both sides of it.
  3. after the 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:

middleware/logger.ts
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:

types
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):

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

ts
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 request

authMiddleware

Attaches an auth token to every request. Optionally refreshes the token on 401:

ts
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:

ts
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:

ts
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:

ts
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:

middleware/index.ts
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

middleware/auth.ts
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:

middleware/unwrap.ts
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

middleware/sentry.ts
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:

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