Skip to content

Getting StartedQuick Start

Quick Start

Set Courier up in one command, or by hand in three steps: define your API, set up a caller, make your first typed request.

3 min readboth modes

In one command

Run it in your project — Next.js, Nuxt, SvelteKit, Astro, React Router, SolidStart, React or Vue with Vite, Expo, Express, Hono, Fastify or NestJS:

npx @noverax/create-courier

It shows the plan, and once you agree adds a typed example API, the route the browser calls it through, courier.config.ts, a mock and a page at /courier; installs the packages with your package manager; and type-checks what it added. Then:

bash
npm run studio   # Courier Studio, and the mock API it runs
npm run dev      # your app: open /courier

The page lists the users your API answers with — from the mock until API_URL names your backend — and creates one from a form; every call shows in Studio. For a new project, name a folder and the framework: npx @noverax/create-courier shop --framework next. Create Courier has every option, and what it changes in each framework.

By hand

The same setup, step by step.

Step 1: Define your API

Create a file with your API definition using courier, defineGroup, and defineApi:

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

const { endpoint } = courier()

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

const users = defineGroup({
  prefix: "users",
  endpoints: {
    getAll: endpoint.get("").response<User[]>(),
    getById: endpoint.get(":id").variables<{ id: string }>().response<User>(),
    create: endpoint.post("").variables<{ name: string; email: string }>().response<User>(),
    update: endpoint.put(":id").variables<{ id: string; name: string }>().response<User>(),
    remove: endpoint.del(":id").variables<{ id: string }>().response<null>(),
  },
})

export const api = defineApi({
  baseURL: process.env.API_URL!,
  groups: { users },
})

Path parameters

The :id syntax automatically extracts id from variables and inserts it into the URL. Remaining variables become query params (GET) or body (POST/PUT/PATCH).

Step 2: Set up the caller

Choose the mode that fits your use case:

Direct mode — call your backend from any runtime:

lib/caller.ts
import { createCaller } from "@noverax/courier"
import { api } from "@/api/sources"

export const caller = createCaller(api)

Proxy mode — browser requests go through your server (hides API keys, bypasses CORS). A boundary names the endpoints the browser may call:

api/boundary.ts
import { defineBoundary } from "@noverax/courier/define"

export const boundary = defineBoundary({
  source: "api",
  expose: { "users.getAll": "query", "users.getById": "query", "users.create": "mutation" },
})

The API holds it, so the server refuses every other endpoint — add boundary to the defineApi call in api/sources.ts, imported from ./boundary. Then the browser's caller takes it in place of the API:

lib/caller.ts
import { createProxyCaller } from "@noverax/courier"
import type { api } from "@/api/sources"
import { boundary } from "@/api/boundary"

export const caller = createProxyCaller<typeof api>("api", { boundary })

The caller sends each call to /api/request on your server, which runs it with the sources registered there — your framework guide sets up that route and the registration. See defineBoundary for what the browser learns and what it never does.

React Query — hooks with full type inference:

lib/query.ts
import { createProxyQueryCaller } from "@noverax/courier-react"
import type { api } from "@/api/sources"

export const q = createProxyQueryCaller<typeof api>("api")

Use both modes together

Most apps use proxy/React Query for client-side and direct mode for server-side. Both share the same API definitions.

Step 3: Use it

With React Query:

app/users/page.tsx
"use client"
import { q } from "@/lib/query"

export default function UsersPage() {
  const { data: users, isLoading } = q.users.useQuery("getAll")
  //      ^? User[]

  const { mutate } = q.users.useMutation("create", {
    onSuccess: (user) => console.log("Created:", user.name),
    //                                           ^? string
  })

  if (isLoading) return <p>Loading...</p>

  return (
    <div>
      <ul>
        {users?.map((u) => <li key={u.id}>{u.name} — {u.email}</li>)}
      </ul>
      <button onClick={() => mutate({ name: "Jane", email: "jane@test.com" })}>
        Add User
      </button>
    </div>
  )
}

Direct mode (server-side):

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

export default async function DashboardPage() {
  const users = await caller.users.getAll()
  //    ^? User[]

  return <ul>{users.map((u) => <li key={u.id}>{u.name}</li>)}</ul>
}

Vanilla TypeScript (no framework):

ts
import { createCaller } from "@noverax/courier"
import { api } from "./api/sources"

const caller = createCaller(api)

const users = await caller.users.getAll()
const user = await caller.users.getById({ id: "1" })

What's happening under the hood

When you call caller.users.getById({ id: "5" }):

  1. Courier resolves the users group and getById method
  2. The :id path parameter is extracted from variables → GET /users/5
  3. Middleware chain runs (retry, auth, logging, etc.)
  4. Schema validation runs (if configured)
  5. The typed response flows back to your code

In proxy mode, step 2-5 happen on your server. The browser only sees /api/request.

Next steps