Skip to content

AdvancedPath Parameters

Path Parameters

Dynamic URL segments using :param syntax with built-in regex compilation.

4 min readboth modes

Basic syntax

Use the :paramName syntax in URL paths to define dynamic segments. Courier compiles these paths using an internal regex parser and substitutes values from variables or pathOptions:

api/users.ts
import { courier, defineGroup } from "@noverax/courier/define"

const { endpoint } = courier()

type User = { id: number; name: string }
type Comment = { id: number; body: string }

const users = defineGroup({
  prefix: "users",
  endpoints: {
    // GET /users/:id -> compiled to GET /users/5
    getById: endpoint.get(":id").variables<{ id: number }>().response<User>(),

    // GET /users/:userId/posts/:postId/comments/:commentId
    getComment: endpoint.get(":userId/posts/:postId/comments/:commentId").variables<{
      userId: number
      postId: number
      commentId: number
    }>().response<Comment>(),
  },
})

Typed parameters

An endpoint's variables follow its path. With none declared, the variables are the path's parameters, each one required. Variables declared before or after the path must name each of its parameters: one left out is a type error where the endpoint is written, not a failed request later.

api/articles.ts
import { courier, defineGroup } from "@noverax/courier/define"

const { endpoint } = courier()

type Article = { id: number; title: string }

export const articles = defineGroup({
  prefix: "articles",
  endpoints: {
    // variables: { id: string | number }
    getById: endpoint.get(":id").response<Article>(),
    update: endpoint.put(":id").variables<{ id: number; title: string }>().response<Article>(),
    // @ts-expect-error — the variables do not name :id
    getBySlug: endpoint.get(":id").variables<{ slug: string }>().response<Article>(),
  },
})

Variables that fill no parameter by name — a form, a raw body, a list — are not checked, and neither are the parameters of a group's prefix, which pathOptions may supply (see below).

Variables as path params

When you pass variables to a request, Courier first extracts any values that match path parameter names. The remaining variables are sent as query parameters (for GET/DELETE) or as the request body (for POST/PUT/PATCH):

Variable consumption
// Path params are extracted from the variables object.
// Remaining variables go to query params (GET) or body (POST).

const users = defineGroup({
  prefix: "users",
  endpoints: {
    getById: endpoint.get(":id").variables<{ id: number }>().response<User>(),
    getWithDetails: endpoint.get(":id").variables<{ id: number; include?: string }>().response<User>(),
    updateAvatar: endpoint.post(":id/avatar").variables<{ id: number; name: string }>().response<void>(),
  },
})

// GET /users/5 — { id: 5 } consumed by :id
await caller.users.getById({ id: 5 })

// GET /users/5?include=posts — { id: 5 } consumed by :id, { include: "posts" } -> query param
await caller.users.getWithDetails({ id: 5, include: "posts" })

// POST /users/5/avatar — { id: 5 } consumed by :id, { name: "photo.jpg" } -> JSON body
await caller.users.updateAvatar({ id: 5, name: "photo.jpg" })

Automatic extraction

You do not need to separate path params from body/query params manually. Courier handles this automatically based on the path definition. Just pass all variables in a single object.

pathOptions function

For path parameters that come from server-side context (cookies, headers, session) rather than the caller, use the pathOptions function. It runs on the server before every request and provides values for path compilation:

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

const { endpoint } = courier()

type User = { id: number; name: string }

const users = defineGroup({
  prefix: "orgs/:orgId/users",
  endpoints: {
    getAll: endpoint.get("").response<User[]>(),
    getById: endpoint.get(":id").variables<{ id: number }>().response<User>(),
  },
})

const api = defineApi({
  baseURL: process.env.API_URL,
  groups: { users },
  // pathOptions provides values for path params that come from context,
  // not from the caller's variables
  pathOptions: async (req) => {
    const cookieHeader = req?.headers.get("cookie") ?? ""
    const orgId = cookieHeader.match(/orgId=([^;]+)/)?.[1]
    return { orgId }
  },
})

// Now every request automatically includes :orgId from the cookie:
// GET /orgs/42/users       — orgId from pathOptions
// GET /orgs/42/users/5     — orgId from pathOptions, id from variables

Server-side only

The pathOptions function receives the Web Standard Request object and runs on the server inside the handler. It has access to cookies, headers, and any server-side context.

Group-level pathOptions

Each group can define its own pathOptions that override the source-level values for the same keys. This is useful when different groups resolve the same parameter differently:

api/groups.ts
import { courier, defineGroup } from "@noverax/courier/define"

const { endpoint } = courier()

type Project = { id: number; name: string }
type Task = { id: number; title: string }

const projects = defineGroup({
  prefix: "workspaces/:workspaceId/projects",
  // Group-level pathOptions override source-level pathOptions
  pathOptions: async (req) => {
    // For this group, workspaceId comes from a header
    const workspaceId = req?.headers.get("x-workspace-id")
    return { workspaceId }
  },
  endpoints: {
    getAll: endpoint.get("").response<Project[]>(),
    getById: endpoint.get(":id").variables<{ id: number }>().response<Project>(),
  },
})

const tasks = defineGroup({
  // No group pathOptions — uses the source-level pathOptions if any
  prefix: "tasks",
  endpoints: {
    getAll: endpoint.get("").response<Task[]>(),
  },
})

How variables flow

Understanding how variables are consumed is important. Here is the complete resolution order:

  1. Source-level pathOptions(request) is called
  2. Group-level pathOptions(request) is called (overrides source values)
  3. Caller's variables fill the path parameters pathOptions did not return
  4. Path parameters are extracted and consumed
  5. Remaining variables go to query params or body
typescript
// The flow of variables through path compilation:

// 1. pathOptions() is called -> returns { orgId: "42" }
// 2. variables from the caller fill the rest: { orgId: "42", id: 5, include: "posts" }
// 3. Path ":orgId/users/:id" consumes orgId and id
// 4. Remaining { include: "posts" } goes to query params (GET) or body (POST)
// 5. Final URL: GET /orgs/42/users/5?include=posts

// Group pathOptions override source pathOptions for the same key:
// Source pathOptions:  { orgId: "42" }
// Group pathOptions:   { orgId: "99" }
// Result:              { orgId: "99" }  <- group wins

The server's value always wins

A key pathOptions returns belongs to the server. A caller's variable of the same name is ignored and is not sent upstream, so a browser cannot name another tenant through the proxy. The key stays the server's when its value is undefined — a request without the session cookie is refused with a 400 instead of taking the caller's value.

To let the caller choose a path parameter, leave it out of pathOptions and declare it in the endpoint's variables.

Path compilation caching

Courier caches compiled path functions for performance. Each unique path string is compiled once using an internal regex parser and reused across all subsequent requests:

typescript
// Path compilation functions are cached for performance.
// The first call to compile "/users/:id" creates and caches the regex.
// Subsequent calls reuse the cached function.

// This means paths like "/users/:id" are compiled once,
// no matter how many times they are called with different variables.

Missing and unsafe path params

A path parameter that neither pathOptions nor the variables provide, or that is undefined or null, fails the call with an HTTPError 400 before any request is made. Its message, and data.message through the proxy too, name the parameter and where its value comes from: Missing required path parameter "id": pass it in the variables, or return it from pathOptions. So does a value of "", "." or "..": each would address a different route than the endpoint declares — .. one level up, even when encoded — so it is refused rather than sent. Any other value, including the word "undefined", is encoded into its segment as it is.

The check happens when the call is made, not when the endpoint is defined: the variables type may leave a parameter out on purpose, because pathOptions on the source or the group can fill it. A stream opened directly refuses a missing parameter the same way, with a RealtimeValidationError.

Next Steps