Overview
createHandler creates a universal request handler that acts as the server-side half of proxy mode. It receives requests from createProxyCaller, validates them, and forwards them to your backend via executeRequest.
import { createHandler } from "@noverax/courier"
const { handle, handleGet, handlePost } = createHandler({ sources })
// handle: (request: Request) => Promise<Response> — auto-routes GET/POST
// handleGet: (request: Request) => Promise<Response> — query endpoints
// handlePost: (request: Request) => Promise<Response> — mutations + dataThe handler works with Web Standard Request/Response objects, making it compatible with any JavaScript runtime and framework.
Setup
Step 1: Define your sources
The sources object maps source names to API definitions created with defineApi:
import { courier, defineApi, defineGroup } from "@noverax/courier/define"
const { endpoint } = courier()
type User = { id: number; name: string; email: string }
type Post = { id: number; title: string; body: string }
const users = defineGroup({
prefix: "users",
endpoints: {
getAll: endpoint.get("").response<User[]>(),
getById: endpoint.get(":id").variables<{ id: number }>().response<User>(),
},
})
const posts = defineGroup({
prefix: "posts",
endpoints: {
list: endpoint.get("").response<Post[]>(),
create: endpoint.post("").variables<{ title: string }>().response<Post>(),
},
})
const api = defineApi({
baseURL: process.env.API_URL ?? "http://localhost:4000",
groups: { users, posts },
})
export const sources = { api }
export type Sources = typeof sourcesStep 2: Create the server handler
Choose the adapter that matches your framework:
// instrumentation.ts
import { registerSources } from "@noverax/courier"
export async function register() {
if (process.env.NEXT_RUNTIME === "nodejs") {
const { sources } = await import("./api")
registerSources(sources)
}
}
// app/api/request/route.ts
import { apiRoute } from "@noverax/courier-next"
export const { GET, POST } = apiRoute()All adapters use createHandler
Every framework adapter is a thin wrapper around createHandler() from @noverax/courier. Sources are read lazily from the core global registry populated by registerSources() (or by attachRuntime() auto-loading courier.config.ts). If your framework is not listed, use createHandler directly with Web Standard Request/Response.
How it works
All adapters delegate to createHandler(), which returns three methods:
handle(request)— auto-routes GET and POST, returns 405 for other methodshandleGet(request)— checks the origin, parses query parameters (_s,_g,_m+ variables) for cacheable read endpointshandlePost(request)— checks the origin, parses JSON or FormData body for mutations
Origin validation
Every call the proxy serves — GET and POST here, and the SSE and WebSocket routes too — is checked for where it comes from, so a page on another site cannot drive the proxy with the user's cookies:
Sec-Fetch-Sitedecides when the browser sends it, and a current browser sends it on every request to a secure origin:same-origin, andnonefor what the user typed, pass;same-siteandcross-sitepass only from an origin listed inallowedOrigins.Originis compared with the origin the request was addressed to where the browser says nothing else; a foreign origin passes only when listed.- A request with neither header — a server, a CDN, a script — is not a browser's cross-origin request, and is served.
- In development, while
allowedOriginsis empty, a page on another port of the local machine is admitted when the app itself is served from that machine, so Studio can inspect the app. An app on any other address never admits one — a server started withoutNODE_ENVcounts as in development, and a page on a visitor's own machine, a local tool or another dev server, is not the application's. ConfiguringallowedOriginsreplaces the allowance with the list.
A refused call is answered 403 with { "success": false, "message": "Origin not allowed" }; a refused socket is closed with 4403. A pattern is an exact origin, https://*.example.com for the sub-domains of a host over one scheme and port, or *.example.com for them over any scheme on its default port:
import { createHandler } from "@noverax/courier"
const handler = createHandler({
allowedOrigins: ["https://admin.example.com", "https://*.example.com"],
})An entry a browser could never send — https://admin.example.com/ with its trailing slash, a path, :443 on HTTPS — stops the handler from being created, with INVALID_ALLOWED_ORIGIN naming the form to use.
Content-Type handling
The POST handler accepts two content types and rejects everything else:
// The handler accepts two content types:
// The endpoint is named in the URL for both: ?_s=api&_g=users&_m=create
// 1. application/json — standard requests
// Body: { variables, options } — any other field is refused with 400
// 2. multipart/form-data — file uploads
// Fields: _options first, then the user's data and files
// Anything else returns 415 Unsupported Media Type
// { success: false, message: "Unsupported Content-Type" }Cookies
An API that keeps the user's session describes it with session: the proxy then sends it the cookies the session is kept in and nothing else of the browser's, repeats its double-submit token on writes, and hands the browser the session cookies it sets, made for your application's origin. For an API that keeps none, cookies cross the proxy only where you name them, one direction at a time:
cookie— the browser's cookies are forwarded to the backend.set-cookie— the cookies the backend sets are passed back to the browser.
Name them where the rest of the header allowlist lives: on the source with allowedHeaders, on a base with courier().extend({ allowedHeaders }), or on one endpoint with .headers([...]). Left unnamed, neither crosses. The browser sends the proxy route every cookie of your application's origin, its own session among them, and a backend a source points at, a third party's included, receives them and sets cookies on your origin only where you say so.
A backend-for-frontend sign-in names the session where it travels:
import { courier, defineApi, defineGroup } from "@noverax/courier/define"
const { endpoint } = courier()
const auth = defineGroup({
prefix: "auth",
endpoints: {
// The backend answers a sign-in with its session cookie
login: endpoint.post("login").variables<{ email: string; password: string }>().headers(["set-cookie"]),
// Calls made as the user send the session back
me: endpoint.get("me").response<{ id: string; name: string }>().headers(["cookie"]),
// Signing out sends the session and clears it
logout: endpoint.post("logout").headers(["cookie", "set-cookie"]),
},
})
export const api = defineApi({ baseURL: process.env.AUTH_API_URL!, groups: { auth } })const caller = createProxyCaller<typeof api>("api")
await caller.auth.login({ email, password }) // the session cookie reaches the browser
const me = await caller.auth.me() // and goes back to the backend with this callWhen the whole backend is your own, name both once for every endpoint: defineApi({ baseURL, allowedHeaders: ["cookie", "set-cookie"], groups }). The browser sends its cookies to the proxy route because credentials: "include" is the proxy client's default.
set-cookie handling
A named set-cookie is passed back on every answer, an error included, so a sign-out that fails still clears the session. Courier uses getSetCookie() to keep each cookie apart, avoiding the common bug of splitting cookie values on commas.
Cancelled calls
When the browser cancels a call — an aborted upload, a query it no longer needs — the handler stops its own request to the backend. It learns that the client has gone from the request's signal, and not every framework fires it:
| Server | A cancelled call stops upstream |
|---|---|
Express, Fastify, NestJS, the standalone server, Hono, Next.js, @astrojs/node | Yes |
SvelteKit with @sveltejs/adapter-node | Yes: the adapter watches the socket from platform.req |
| SolidStart, dev and production | Yes: the adapter watches the response on nativeEvent |
| React Router 7 | Only with future: { v8_passThroughRequests: true }: without it a resource route receives a rebuilt copy of the request whose signal does not reliably follow the original |
| Nuxt, dev and production | Yes: the adapter converts Node's request so its signal follows the connection, and a Web request h3 1.x hands over is watched through the event's Node response |
vite dev for SvelteKit, astro dev | No: the dev server exposes neither a signal nor the connection |
An integration of your own that can reach the Node socket or response passes it to handleWhileConnected, which gives the handler a request whose signal also fires when that connection closes:
import { createHandler, handleWhileConnected } from "@noverax/courier"
const { handle } = createHandler()
// res is the Node response your framework is answering on
const response = await handleWhileConnected(handle, request, res)Error responses
The handler returns consistent JSON error responses with appropriate HTTP status codes:
// Error responses follow a consistent format:
// HTTP errors from the backend:
// Status: forwarded from backend (e.g., 404, 422, 500)
// Body: parsed JSON from backend response
// Validation errors (missing source, endpoint not found):
// Status: 400
// Body: { success: false, message: "[api][users][getById]: method not found" }
// Origin mismatch:
// Status: 403
// Body: { success: false, message: "Origin mismatch" }
// Invalid JSON body:
// Status: 400
// Body: { success: false, message: "Invalid JSON body" }Next Steps
- createProxyCaller — configure the browser-side client
- Path Parameters — dynamic URL segments
- Encoding — custom body and query serialization