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

OptionTypeDescription
urlstringBase URL of the CMS API (required)
apiKeystringAPI key for authentication
headersRecord<string, string> | () => Record<string, string>Custom headers (static object or function)
credentialsRequestCredentialsFetch credentials mode (e.g. "include" for cookies)
fetchtypeof fetchCustom fetch implementation
signalAbortSignalDefault 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
        
  • fields narrows the root columns (omit it to return all).
  • resolve widens 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:

ParamTypeDescription
filterFilterFilter records — see Querying
sortSortInputOrderable column, or array of them; - prefix = descending — see below
fieldsselectionNarrow the root columns — see below
resolveselectionExpand relations — see below
limitnumberMax records to return (list only)
offsetnumberSkip N records (list only)
signalAbortSignalAbort 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 (id always 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 hopsresolve: { 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