The typed client
@navree/client: a client typed from your API's OpenAPI document, NavreeError with Navree's error codes, list queries, pages, query keys and operations.
On this page
@navree/client calls a Navree API with types generated from the API's own OpenAPI document, so a renamed field or a removed route is a compile error in the front end. It is openapi-fetch with Navree's conventions built in: the error shape and its code, the workspace header, request ids, list queries and the page shapes.
Generating the schema
navree openapi:client exports the app's document and runs openapi-typescript on it:
navree openapi:client # web/src/lib/api/openapi.json and schema.d.ts
navree openapi:client --out web/app/lib/api # a Nuxt app
navree openapi:client --input openapi.json # from a document you already have| Option | Default | Meaning |
|---|---|---|
--out DIR | web/src/lib/api | Where openapi.json and schema.d.ts go, relative to the app. |
--input FILE | none | Generate from this document instead of running openapi:export. |
The command runs cargo run --quiet -- openapi:export <out>/openapi.json, then npx --yes openapi-typescript@7 <out>/openapi.json --output <out>/schema.d.ts. Commit both files and run the command again after changing routes or types; a CI step that runs it and fails on a diff keeps the front end and the API in step.
Creating the client
import { createNavreeClient } from "@navree/client";
import type { paths } from "./schema";
export const api = createNavreeClient<paths>();import { unwrap } from "@navree/client";
import { api } from "@/lib/api/client";
const deal = await unwrap(api.GET("/api/v1/deals/{id}", { params: { path: { id } } }));
const created = await unwrap(api.POST("/api/v1/deals", { body: { title: "Acme renewal", amount_cents: 120_000 } }));GET, POST, PUT, PATCH and DELETE take the path as the document writes it; the path parameters, query and body are typed from it, and so is the data. unwrap returns the data or throws a NavreeError.
Cookie mode and bearer mode
With no token, the client is in cookie mode: requests go to the page's own origin with credentials: "same-origin" and no Authorization header. That is the mode for a browser behind a backend-for-frontend, which holds the tokens in httpOnly cookies.
With a token, the client is in bearer mode: it sends Authorization: Bearer <token> and omits cookies. Use it on servers, in scripts and in apps that hold the token themselves:
const api = createNavreeClient<paths>({
baseUrl: "https://api.example.com",
token: () => session.accessToken, // asked before every request; null sends none
});Options
| Option | Default | Meaning |
|---|---|---|
baseUrl | "" | Where the API (or the BFF) is. Empty means the page's own origin. |
token | none | A token, or a function returning one (sync or async). Turns on bearer mode. |
credentials | same-origin in cookie mode, omit in bearer mode | Fetch credentials. |
workspace | none | The current workspace, or a function returning it; sent as workspaceHeader. |
workspaceHeader | X-Workspace | The API's workspace.header. |
requestId | true | X-Request-Id on every request: true makes a UUID, a function makes your own, false sends none. |
headers | {} | Headers for every request; a request's own headers win. |
fetch | globalThis.fetch | The fetch to use (tests, server runtimes). |
onUnauthorized(response, request) | none | Called on every 401: send the browser to sign-in. |
onForbidden(error, request) | none | Called on every 403 with the parsed NavreeError: a toast. |
Every request id is logged by the API with the request, and comes back on errors as error.requestId, so a support ticket can quote it.
Requests that aren't JSON
api.raw(path, init) sends a plain request with the same headers and hooks, for CSV exports, event streams and channel authorization:
const response = await api.raw("/api/v1/contacts/export?filter[status]=customer", { headers: { Accept: "text/csv" } });
const csv = await response.text();List endpoints
Until an endpoint's OpenAPI entry describes its list query, give the client the query type with WithQuery:
import { createNavreeClient, type ListQuery, type WithQuery } from "@navree/client";
import type { paths as Generated } from "./schema";
export type paths = WithQuery<Generated, {
"/api/v1/deals": ListQuery;
"/api/v1/contacts": ListQuery & { status?: "lead" | "customer" };
}>;
export const api = createNavreeClient<paths>();Errors
Every error from a Navree API has the same body, and NavreeError carries all of it:
{
"statusCode": 409,
"message": "The invoice has already been sent.",
"error": "Conflict",
"code": "invoice_already_sent"
}| Member | Value |
|---|---|
status | The HTTP status; 0 when the request got no answer. |
code | The body's code, or the status's default (not_found, unauthenticated, validation_failed for a 400 with field errors, network_error for no answer). |
message | The body's message; the messages of a validation error joined with ; . |
messages | message as a list. |
errors | Field errors of a validation failure ({field: [message]}), empty otherwise. |
fieldError(field) | The first message for field. |
isValidation | A 400 with field errors or a list of messages. |
is(...codes) | True when code is one of codes. |
requestId | The response's X-Request-Id. |
body | The body as sent, with code filled in. |
import { errorMessage, isNavreeError } from "@navree/client";
try {
await unwrap(api.POST("/api/v1/invoices/{id}/send", { params: { path: { id } } }));
} catch (error) {
if (isNavreeError(error) && error.is("invoice_already_sent")) return showSentBadge();
toast.error(errorMessage(error));
}errorMessage(error, fallback?) is a sentence for a toast, in English: the API's own message when it says something specific, else a sentence for the status ("That record no longer exists." for a 404, "Too many attempts. Wait a minute and try again." for a 429, "Can't reach the server. Check your connection." with no answer). For other languages, translate the code instead: see localization. The codes the framework sends are listed on the errors page.
List queries
A list endpoint reads filters, sort, search and paging from the query string. listQuery() builds one; every call returns a new builder:
import { listQuery } from "@navree/client";
const query = listQuery()
.where("stage", "won")
.where("created_at", "gte", "2026-10-01")
.whereIn("owner_id", ["u1", "u2"])
.sort("-amount_cents", "title")
.search("acme")
.page(2)
.perPage(25);
await unwrap(api.GET("/api/v1/deals", { params: { query: query.toQuery() } }));filter[stage]=won&filter[created_at][gte]=2026-10-01&filter[owner_id][in]=u1,u2&sort=-amount_cents,title&search=acme&page=2&per_page=25| Builder call | Query string |
|---|---|
where("stage", "won") | filter[stage]=won |
where("amount_cents", "gte", 1000) | filter[amount_cents][gte]=1000 (several operators on one field add up) |
whereIn("stage", ["lead", "won"]) or where("stage", ["lead", "won"]) | filter[stage][in]=lead,won |
where("owner_id", "null", true) | filter[owner_id][null]=true |
without("stage") | removes the filter |
sort("-created_at", "name") | sort=-created_at,name (- sorts descending) |
search("acme") | search=acme |
page(2), perPage(25) | page=2&per_page=25 |
cursor("eyJpZCI6NDJ9"), limit(50) | cursor=eyJpZCI6NDJ9&limit=50 (drops page and per_page) |
The operators are eq, ne, gt, gte, lt, lte, in, not_in, contains, starts_with and null. Dates are sent as ISO 8601, booleans and numbers as text, and lists joined with commas.
The same format works as data: serializeListQuery(query) writes URLSearchParams, parseListQuery(input) reads a query string, URLSearchParams or a Next.js searchParams object (ignoring parameters it doesn't own), and the client's query serializer writes a ListQuery object in this format. setFilter(query, field, value) changes one filter and goes back to page 1, withPage(query, n) moves to a page, and toggleSort(sort, field) cycles a column through ascending, descending and off.
Pages
List endpoints answer offset pages or cursor pages:
{"data": [...], "meta": {"current_page": 2, "per_page": 25, "total": 60, "last_page": 3}}
{"data": [...], "next_cursor": "eyJpZCI6NDJ9"}| Helper | Returns |
|---|---|
isPage(value), isCursorPage(value) | Which shape a value has. |
hasNextPage(page) | Whether another page follows. |
pageRange(meta) | {from, to}: the rows the page shows, counting from 1 (26–50). |
facetCounts(meta, field) | [{value, count, label?}] from meta.facets, whether the API sends a map of counts or a list. |
pages(fetchPage, query) | An async iterator over every page, by page number or by cursor. |
collectAll(fetchPage, query, {maxPages}) | Every row of every page. |
import { collectAll } from "@navree/client";
const everyone = await collectAll((query) => unwrap(api.GET("/api/v1/users", { params: { query } })), { per_page: 100 });Query keys
queryKeys(resource) gives one family of TanStack Query keys per resource, nested so one invalidation reaches every list and record:
import { queryKeys } from "@navree/client";
export const deals = queryKeys("deals", { board: (owner?: string) => ["board", owner ?? "all"] as const });
useQuery({ queryKey: deals.list(query), queryFn: () => unwrap(api.GET("/api/v1/deals", { params: { query } })) });
useQuery({ queryKey: deals.detail(id), queryFn: () => unwrap(api.GET("/api/v1/deals/{id}", { params: { path: { id } } })) });
queryClient.invalidateQueries({ queryKey: deals.all }); // ["deals"]: lists, records and the board| Key | Value |
|---|---|
deals.all | ["deals"] |
deals.lists() | ["deals", "list"] |
deals.list(query) | ["deals", "list", query] |
deals.details() | ["deals", "detail"] |
deals.detail(id) | ["deals", "detail", "42"] |
deals.board("u1") | ["deals", "board", "u1"] |
Operations
Long work a user starts (an import, an export, a bulk change) is an operation the user follows: the queue module serves it at GET /api/operations/{id}, DELETE /api/operations/{id} and GET /api/operations/{id}/events. followOperation reads its server-sent event stream with fetch, so it works through the BFF and with bearer tokens, and returns the finished operation:
import { cancelOperation, followOperation } from "@navree/client";
const { operation_id } = await unwrap(api.POST("/api/v1/contacts/import", { body: form }));
const finished = await followOperation<{ imported: number }>(operation_id, {
fetch: api.raw,
onUpdate: (op) => (progress.value = op.progress?.percent ?? 0),
});
if (finished.status === "succeeded") toast(`Imported ${finished.result?.imported} contacts`);| Option | Default | Meaning |
|---|---|---|
fetch | fetch on the page's origin | Sends the requests (api.raw adds the client's headers). |
basePath | /api/operations | Where the routes are mounted. |
onUpdate(operation) | none | Called on every change, the first state included. |
signal | none | Stops following; the promise rejects with the abort reason. |
retries | 5 | Reconnects in a row without a new event before giving up with stream_closed. |
retryDelayMs | 1000 | Reconnect n waits n × retryDelayMs. |
A dropped stream is resumed with Last-Event-ID. A 404 (an operation the user may not see) throws a NavreeError at once. cancelOperation(id) sends DELETE and returns the cancelled operation, getOperation(id) loads it, and isFinished(op) is true for succeeded, failed and cancelled. readEventStream(body) is the event-stream parser underneath, for your own streams.
Abilities and real time
Two entry points need a library of their own, so they are separate imports:
@navree/client/abilities(needs@casl/ability):createAbility(rules)builds a CASL ability that checks API records by their__type, andfetchAbilities({fetch})loads the rules fromGET /api/me/abilities.@navree/client/realtime(needspusher-js):connectRealtime({server, authorize})connects to the API's Pusher-protocol server.listen(channel, event, callback)returns a function that stops listening, and a channel is left when its last listener stops. A bare channel name is private (crm.dealsisprivate-crm.deals),publicChannel("status")is public, andpresence-names stay as they are.createMemoryRealtime()is the same interface without a socket, for tests and mock modes:emit(channel, event, data)plays the server.
The Next.js and Nuxt packages wrap both in providers and hooks.