No codegen · no schema files · any backend

Type-safe HTTP for any framework

Define your API once in plain TypeScript. Get end-to-end inference, one proxy route for every endpoint, React Query bindings and middleware — against any REST backend, which needs zero changes.

Start in 60 seconds
api/sources.ts
app/users/page.tsx
// the only place your API is describedconst { endpoint } = courier()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>(), },})export const api = defineApi({ baseURL: process.env.API_URL!, groups: { users },})
// anywhere in the app — nothing regenerated
✓ 0 errors · every call typed from api/sources.ts

WORKS WHERE YOU ALREADY ARE

Ten official server adapters, each one line to mount — and any client that can call fetch.

  • Next.js: apiRoute()
  • Nuxt: courierHandler()
  • SvelteKit: courierHandler()
  • Astro: courierHandler()
  • Remix: courierHandler()
  • SolidStart: courierHandler()
  • Hono: courierHandler()
  • Express: courierHandler()
  • Fastify: courierPlugin
  • NestJS: courierMiddleware()
  • React: @noverax/courier-react
  • Vue: createProxyCaller
  • Node.js: direct mode
  • Bun: via Hono
  • Deno: via Hono
  • Cloudflare Workers: via Hono

SCROLL TO REFACTOR

The code you get to delete

−0lines

3 files · 20 lines left

app/users/[id]/page.tsx
api/users/[id]/route.ts
lib/query-keys.ts
3 files
1const res = await fetch(`${process.env.NEXT_PUBLIC_API}/users/${id}`, {
2 headers: {
3 "Content-Type": "application/json",
4 Authorization: `Bearer ${token}`,
5 },
6 signal: controller.signal,
7})
8if (!res.ok) {
9 const body = await res.text()
10 throw new Error(`${res.status}: ${body}`)
11}
12const user = (await res.json()) as User // a hope, not a type
13// app/api/users/[id]/route.ts — one per endpoint
14export async function GET(req: Request) {
15 // origin check, cookie forwarding, error mapping…
16 // …and the same again for POST, PUT, DELETE
17}
18// lib/query-keys.ts — kept in sync by hand
19export const userKeys = { byId: (id) => ["users", id] }
20const { data } = useQuery({ queryKey: userKeys.byId(id), queryFn })
⚠ 3 files, 1 type assertion, 0 compile-time guarantees

The parts everyone hand-rolls, built in

Each one is shown running below. All of it ships with Courier — nothing to find, nothing to wire by hand.

release-assets.zip
0.0 / 2.4 MB
0%
XHR · multipart/form-data3.2 MB/s

UPLOADS

Upload progress that is actually real

In the browser it comes from XHR: onUploadProgress reports percent and bytes as they leave the machine — not a jump from 0 to 100.

await caller.files
  .with({ onUploadProgress: (p) => setPct(p.percent * 100) })
  .upload(form)
What does Courier do?
text/event-streamchunk 0 / 19

STREAMING

Streams render as they arrive

One option turns the response into a stream. SSE and chunked bodies paint token by token.

const body = await caller.chat
  .with({ stream: true })
  .ask({ q })
<UserCard /><Sidebar /><Header />same keyGET /users/53 reads → 1 request

DEDUPLICATION

Identical reads collapse into one

Three components ask for the same user. One derived query key, one request.

// the same read in three components
q.users.useQuery("getById", {
  variables: { id: "5" },
})
logMiddleware()
retryMiddleware({ limit: 3 })
authMiddleware({ getToken })
fetch
→log api:users:getById

MIDDLEWARE

An onion you can actually read

Koa-style middleware wraps every call in both directions — logging, retry, auth, rate limits and caching compose in the order you list them.

middleware: [
  logMiddleware(),
  retryMiddleware({ limit: 3 }),
  authMiddleware({ getToken }),
]
name
Ada Lovelace
email
a

VALIDATION

Bad input never leaves

Pass a Zod, Valibot or ArkType schema to .variables(). Types are inferred from it and invalid data is stopped before the network.

create: endpoint.post("")
  .variables(CreateUser)
  .response(UserSchema)
catch (error)unknown
isHTTPError<ApiError>(error)true
error.status404
error.data.message"User not found"

ERRORS

Errors you can narrow, not guess

Every non-2xx rejects with an HTTPError carrying status, headers and a typed body. The guard narrows unknown in a single line.

if (isHTTPError<ApiError>(error)
    && error.status === 404) notFound()

One route. Every endpoint.

Not a handler per endpoint — a single zero-argument handler that reads your registered sources and does the tedious parts for you.

  • Validates the request origin — CSRF handled for you
  • Carries the headers and cookies you name to the backend, and back
  • Routes to the right source, group and method
  • Maps failures onto proper HTTP status codes
  • Supports multipart uploads and custom encoding
// app/api/request/route.ts
import { apiRoute } from "@noverax/courier-next"

export const { GET, POST } = apiRoute()
BrowserServer comp.Vue · NuxtExpressFastAPIGo · RailsapiRoute()your serverCLIENTSANY REST API

Your backend can be Express, FastAPI, Rails, Go or Laravel. Courier asks nothing of it — unlike tRPC, which requires the server to be yours.

MCP SERVER

Your API, in every agent's hands

Mark an endpoint .agent() and Claude Code, Cursor or any MCP client can call it — typed from your schemas, run through your middleware, and recorded in Studio.

  • Only the endpoints you offer — every other one stays out of reach
  • Arguments and answers typed from the schemas you already wrote
  • Every call through your middleware: auth, limits, audit
  • A person confirms anything that cannot be undone
  • Every agent call in Studio, beside your app's own
cancel: endpoint
  .post(":id/cancel")
  .variables(z.object({ id: z.string() }))
  .agent({
    description: "Cancel an order that has not shipped yet",
    access: "destructive",
  })
Explore the MCP server

Where it sits

Full inference like tRPC — but against a REST backend you do not control.

Courier compared with fetch, axios and tRPC
FEATURE
fetch2 / 12
axios3 / 12
tRPC7 / 12
courier12 / 12
Type safetyManual castsManual castsFullFull inference
Any REST backendYesYestRPC server onlyUnchanged
No code generationNoneNoneNoneNone
React QueryManualManualBuilt-inBuilt-in
Server componentsManualManualServer callersFirst-class
MiddlewareNoInterceptorsBuilt-inKoa-style chain
Runtime validationNoNoAny validatorAny Standard Schema
Upload progressNoYesNoBuilt-in
Typed streams & socketsManualNoSubscriptionsSSE and WebSocket
Retries & cachingManualPluginsVia React QueryBuilt-in
Mock server from the APISeparate toolSeparate toolSeparate toolFrom the definition
Devtools for every callBrowser onlyBrowser onlyCommunity toolsCourier Studio

Your framework is already supported

Every adapter handler is zero-argument — it reads sources from the registry. Switch frameworks: only the import and one call change.

10
official adapters
2
lines that differ
0
handler arguments
AdaptersAll 10 adapters
app/api/request/route.ts3 linesNext.js guide
1import { apiRoute } from "@noverax/courier-next"
2
3export const { GET, POST } = apiRoute()← framework-specific
$npx @noverax/create-courier --framework next

Your next API call could be the last one you type by hand