Client
Typed JavaScript client for the CMS REST API.
@cms/client is a lightweight fetch wrapper for the CMS API. It works in any JavaScript runtime — browsers, Node, Deno, edge workers, and SSR frameworks.
Setup
import { createClient } from "@cms/client";
import type cmsConfig from "./cms.config";
const cms = createClient<typeof cmsConfig>({
url: "https://cms.example.com/api",
apiKey: "your-api-key",
});
Pass your config as a type-only generic. The runtime never touches the config object — only its type informs the client — so secrets and server-only SDKs (Shopify, etc.) stay out of the bundle. Entity names become a checked union on every method, and responses are inferred from the selection you pass.
Options
| Option | Type | Description |
|---|---|---|
url | string | Base URL of the CMS API (required) |
apiKey | string | API key for authentication |
headers | Record<string, string> | () => Record<string, string> | Custom headers (static object or function) |
credentials | RequestCredentials | Fetch credentials mode (e.g. "include" for cookies) |
fetch | typeof fetch | Custom fetch implementation |
signal | AbortSignal | Default abort signal for all requests |
Typing responses
Reads are types-only — there’s no per-call generic or Zod schema. The response type is computed from the entity name and the inline selection you pass:
const { data: post } = await cms.find("posts", {
fields: { title: true, status: true },
resolve: { author: { name: true } },
});
// ^? { id: string; title: string; status: ...; author: ... } | null
fieldsnarrows the root columns (omit it to return all).resolvewidens relations, replacing the bare{ id, _entity }reference with the resolved object.
The client trusts the server response and never validates at runtime. See Selecting fields and Resolving relations below for the full selection grammar.
Extracting a named type
To name the response type of a specific query, use DocOf from @cms/client. It takes your config type, the entity name, and an optional selection — and unlike a plain schema-based helper, it also includes _translations? for translatable entities:
import type { DocOf, EntityName, Selection } from "@cms/client";
import type { CmsRootConfig } from "./types";
const pageResolve = {
blocks: { hero: { image: { url: true } } },
} as const;
type ResolvedPage = DocOf<CmsRootConfig, "page", { resolve: typeof pageResolve }>;
In a project with many queries, bind the config once and re-export a two-parameter alias:
// src/lib/cms/types.ts
import type { DocOf, EntityName, Selection } from "@cms/client";
import type { InferEntitiesSchema } from "@cms/config";
export type CmsRootConfig = typeof import("~/cms.config").default;
export type CmsSchema = InferEntitiesSchema<CmsRootConfig>;
export type CmsResult<
Name extends EntityName<CmsRootConfig>,
Sel extends Selection<Name, CmsSchema> | undefined = undefined,
> = DocOf<CmsRootConfig, Name, Sel>;
Then every query file uses the shorter form:
import type { CmsResult } from "@/lib/cms/types";
type ResolvedPage = CmsResult<"page", { resolve: typeof pageResolve }>;
type FullPage = CmsResult<"page">; // no Sel → all fields, relations unresolved
Methods
find
Returns the first record matching the query, or null.
find(name, params?): Promise<{ data: Doc | null }>
const { data: post } = await cms.find("posts", {
filter: { slug: { $eq: "/hello-world" } },
fields: { title: true, slug: true },
});
list
Returns a paginated list of records.
list(name, params?): Promise<{
data: Doc[];
meta: { total: number; limit: number; offset: number };
}>
const { data: posts, meta } = await cms.list("posts", {
filter: { status: "published" },
sort: ["-publishedAt", "title"],
limit: 10,
offset: 0,
});
get
Returns a single record by ID (collection) or the singleton value (global).
// Collection — by ID
get(name, id, params?): Promise<{ data: Doc }>
// Global — no ID
get(name, params?): Promise<{ data: Doc | null }>
// Collection
const { data: post } = await cms.get("posts", "some-uuid");
// Global
const { data: settings } = await cms.get("settings");
// Global with locale
const { data: settings } = await cms.get("settings", { locale: "pt" });
Collection and global get results always carry document metadata (_status, _draftCreatedAt). Pass draft: true to fetch the editorial view (canonical merged with the pending draft).
create
Creates a new record. The body is typed from the entity’s config — its schema input minus the engine-managed columns (id, createdAt, …).
create(name, body, params?): Promise<{ data: Doc }>
const { data: post } = await cms.create("posts", {
title: "New post",
status: "draft",
});
update
Updates an existing record by ID (collection) or upserts the singleton (global).
The patch is a shallow Partial of the create body — send only the fields you want to change.
// Collection — by ID
update(name, id, body, params?): Promise<{ data: Doc }>
// Global — no ID
update(name, body, params?): Promise<{ data: Doc }>
// Collection
const { data: post } = await cms.update("posts", "some-uuid", {
status: "published",
});
// Global
const { data: settings } = await cms.update("settings", {
siteName: "New name",
});
delete
Deletes a record by ID and returns its id. Globals can’t be hard-deleted — use drafts.discard or unpublish.
delete(name, id, params?): Promise<{ data: { id: string } }>
await cms.delete("posts", "some-uuid");
request
Low-level method for custom endpoints. Accepts its own schema option for response validation.
request<T>(path: string, options?: RequestOptions<T>): Promise<T>
const health = await cms.request<{ status: string }>("/health");
// With schema validation
const health = await cms.request("/health", {
schema: z.object({ status: z.string() }),
});
Query parameters
Read methods (find, list, get) accept these query parameters:
| Param | Type | Description |
|---|---|---|
filter | Filter | Filter records — see Querying |
sort | SortInput | Orderable column, or array of them; - prefix = descending — see below |
fields | selection | Narrow the root columns — see below |
resolve | selection | Expand relations — see below |
limit | number | Max records to return (list only) |
offset | number | Skip N records (list only) |
signal | AbortSignal | Abort signal for this request |
Selecting fields
fields narrows which root columns come back. Omit it to return every column. It’s a flat { column: true } map typed against the entity, and id is always included:
const { data } = await cms.find("posts", {
fields: { title: true, slug: true },
});
// ^? { id: string; title: string; slug: string } | null
A relation must be selected here to be resolvable — resolving a column you dropped from fields is a compile error.
Resolving relations
By default a relation comes back as a bare { id, _entity } reference. resolve is an additive tree that expands relations in place. A relation key takes:
true(or{}) — resolve all of the target’s fields.- a projection object — resolve only the named fields (
idalways included). - a nested object — chain into the target’s own relations, or traverse a JSONB object/union to reach a relation inside it.
const { data: post } = await cms.find("posts", {
resolve: {
author: { name: true, bio: true }, // projection
tags: true, // all fields, every tag
hero: { cta: { link: { url: true } } }, // traverse JSONB → resolve `link`
},
});
Blocks (arrays of variants)
A relation inside an array of union variants (a page-builder blocks array) is addressed per variant — key by the variant’s _type:
const { data: page } = await cms.find("page", {
resolve: {
blocks: { hero: { cta: { target: { path: true } } } },
},
});
A single (non-array) union field is unqualified — its relation is addressed directly (resolve: { link: { target: { title: true } } }).
Polymorphic targets
A polymorphic relation resolves across all its targets at once. The result is a discriminated union on _entity; a projected field that exists on only some targets is omitted from the branches that lack it (so narrowing on _entity is required to read it). A target the role can’t read stays a bare reference.
Depth
Relation chains are capped at 2 hops — resolve: { owner: { org: { name: true } } } resolves owner then org, and a third relation hop is a compile error. JSONB traversal (drilling through objects/unions toward a relation) is free and doesn’t count against the cap.
Sorting
sort is typed off the entity’s orderable columns. A bare column sorts ascending; a - prefix sorts descending. Pass an array for a multi-key sort (applied left to right):
await cms.list("posts", { sort: "-publishedAt" }); // single key, descending
await cms.list("posts", { sort: ["-publishedAt", "title"] }); // multi-key
Orderable columns autocomplete (both ascending and -descending). Any string is still accepted — for engine-supported paths the row type can’t name (a relation field like author.name, a JSONB path like body.title) or a sort string built at runtime. Because any string passes, a typo in a known column isn’t rejected — that’s the deliberate trade for the escape hatch.
Runtime configuration
Use config() to update client options after creation — useful for setting auth tokens dynamically:
const cms = createClient({ url: "/api" });
// Later, after authentication
cms.config({ apiKey: token });
Error handling
Failed requests throw a ClientError with structured error data:
import { ClientError } from "@cms/client";
try {
await cms.get("posts", "bad-id");
} catch (error) {
if (error instanceof ClientError) {
console.log(error.status); // HTTP status code
console.log(error.code); // Error code (e.g. "NOT_FOUND")
console.log(error.message); // Human-readable message
console.log(error.details); // Optional additional details
}
}
Previous
REST API
Next
Field Types Reference