Client

How to create a Cairn client and use it to read and write content, generated from @cairn-cms/client's own TSDoc comments.

Functions

createClient

function createClient(config: CairnClientConfig): CairnClient
config.apiUrlstringoptionalOverride for local development; defaults to the hosted cairn API.
config.datasetstringoptionalWhich dataset to read from and write to. Reads default to the project's default dataset as the API resolves it (including any delivery route configured for it); writes default to "production". Point this at another dataset's slug - e.g. a separate staging dataset - to serve a site from it. A *branch* isn't publicly readable by its slug; preview a branch through a preview session, or route a live dataset to it.
config.projectIdstringrequiredThe project's slug (see apps/api's auto-generated slugs) - visible in the dashboard's URL (app.cairn.press/<slug>/...) once the project is open.
config.tokenstringoptionalA project API token (see the Dashboard's "API tokens" section), for the write methods below - create/update/delete/publish/ unpublish. The read methods (fetch/fetchOne/fetchSingleton) never need one; they only ever read already-public, published content, the same as this client's image() URLs.

Returns: CairnClient

createImageUrl

A single call that returns the built URL directly - no separate .url() step, no network call, no token.

A bare call, or one passing only a *named* size preset, builds a URL against cdn.cairn.press - a public bucket Cloudflare serves through a small caching Worker (no apps/api request on a cache hit, which is the overwhelming majority of traffic once anything's been requested once). Passing fit/format/quality, or an explicit { w, h } pixel size, falls back to the original, authenticated-capable API route (apiUrl) instead - an arbitrary, caller-chosen transform doesn't have a bounded set of keys to pre-generate or cache indefinitely the way a named preset does (see apps/api's asset route for the same reasoning already applied there).

dataset only matters for that transform fallback path (the CDN path has no notion of dataset - only published content ever reaches it, and publishing always targets whichever dataset originated the publish) - defaults to "production", matching createClient's own dataset option.

function createImageUrl(config: { apiUrl?: string; cdnUrl?: string; dataset?: string; projectId: string }): (source: ImageValue, options?: ImageUrlOptions) => string
config{ apiUrl?: string; cdnUrl?: string; dataset?: string; projectId: string }required

Returns: (source: ImageValue, options?: ImageUrlOptions) => string

createPreviewClient

The preview-mode counterpart to createClient (ROADMAP.md §4) - reads draft (or a specific dataset's published) content through the token-gated /preview/... routes instead of the public, published-only /published/... ones. Meant to be used from the create-cairn preview-route template's pages, not called directly by most integrators; createClient's read methods remain the right choice for a live page that was never meant to show unpublished content.

For asset URLs in preview mode, see createPreviewImageUrl in image.ts - a separate function, not a method here, matching how createImageUrl is already separate from createClient on the published side.

function createPreviewClient(config: CairnPreviewClientConfig): CairnPreviewClient
config.apiUrlstringoptionalOverride for local development; defaults to the hosted cairn API.
config.previewTokenstringrequiredA preview *session* token - what createPreviewHandler (see packages/client/src/next.ts) stores in its HttpOnly cookie after exchanging the single-use grant from the Dashboard's "Preview" action URL param. Not the grant itself, and not something most integrating code mints or handles directly - the preview-route template is normally the only place this ever gets read from a cookie and passed in. Short-lived (~2h) and revocable; a client built with an expired or revoked session throws on first use, not at construction. This is the same token createPreviewImageUrl (see image.ts) needs to build authorized asset URLs - the two are separate calls since not every page needs images.
config.projectIdstringrequired

Returns: CairnPreviewClient

createPreviewImageUrl

The preview-mode counterpart to createImageUrl - builds a secure.cdn.cairn.press URL authorized by a short-lived, asset-scoped token that's minted and cached automatically behind the scenes (and refreshed as needed on later calls). Async, unlike createImageUrl's imageUrl - unavoidably, since minting that token needs a network call the plain published-URL case never does. There's still nothing to sign or verify yourself: this is the only preview asset-URL surface this client has.

Only a bare call or a named preset (see PreviewImageUrlOptions) - apps/cdn-secure can only serve what's already been generated (at upload time, unconditionally, regardless of publish state - so a preset is rarely actually missing on this side), never an arbitrary transform.

function createPreviewImageUrl(config: PreviewImageUrlConfig): (source: ImageValue, options?: PreviewImageUrlOptions) => Promise<string>
config.apiUrlstringoptionalOverride for local development; defaults to the hosted cairn API.
config.previewTokenstringrequiredThe same preview *session* token createPreviewClient uses - see CairnPreviewClientConfig.previewToken's doc comment in client.ts.
config.projectIdstringrequired
config.secureCdnUrlstringoptionalOverride for local development; defaults to the hosted secure/preview asset CDN.

Returns: (source: ImageValue, options?: PreviewImageUrlOptions) => Promise<string>

sanitizeLinkHref

The link target toHTML/<PortableText> will actually render: an http(s)/mailto/tel URL or a relative path/fragment, otherwise null (the link is dropped, its text kept). Content is written by editors and API tokens, and a javascript: href in published rich text would otherwise run script on the customer's own site when clicked. Custom link renderers receive the same sanitized value.

function sanitizeLinkHref(href: string): string | null
hrefstringrequired

Returns: string | null

toHTML

Renders Portable Text blocks to an HTML string - usable directly in any frontend (drop it into innerHTML, or a framework's raw-HTML escape hatch). For a React tree instead of a string, see @cairn-cms/client/react's PortableText component.

function toHTML(blocks: PortableTextBlock[] | null | undefined, components?: PortableTextHtmlComponents): string
blocksPortableTextBlock[] | null | undefinedrequired
components.blockPartial<Record<PortableTextBlockStyle, (children: string) => string>>optional
components.listPartial<Record<PortableTextListItem, (children: string) => string>>optional
components.listItemPartial<Record<PortableTextListItem, (children: string) => string>>optional
components.marksPartial<Record<PortableTextDecorator, (children: string) => string>> & { link?: (children: string, href: string) => string }optional

Returns: string

toPlainText

true to plain-text a Portable Text value even when it's missing the array wrapper - defensive against hand-authored/legacy content.

function toPlainText(blocks: PortableTextBlock[] | null | undefined): string
blocksPortableTextBlock[] | null | undefinedrequired

Returns: string

CairnClient

Returned by createClient().

create

Creates a new draft document. Requires token.

create<T>(type: string, data: T): Promise<Document<T>>
typestringrequired
dataTrequired

Returns: Promise<Document<T>>

delete

Deletes a document outright (draft and published data, and its version history). Requires token.

delete(type: string, id: string): Promise<void>
typestringrequired
idstringrequired

Returns: Promise<void>

fetch

Every published document of a type, ordered the same way the Dashboard orders them - manual rank for orderable types, otherwise the type's own configured default sort (the schema editor's "Sort Entries By"), falling back to most recently published first if none is configured. Pass options.orderBy to override this with an explicit order, or to filter, select specific fields, resolve references, or paginate server-side - see FetchOptions. Throws (rather than returning an empty array) if options describes an invalid query, e.g. an unknown field name.

fetch<T>(type: string, options?: FetchOptions<T>): Promise<PublishedDocument<T>[]>
typestringrequired
options.includestring[]optionalDocument type identifiers to resolve inline wherever referenced. Resolution traverses nested objects/arrays but follows one reference hop. Top-level reference field names are also accepted for compatibility.
options.limitnumberoptionalDefaults to 100, capped at 200 server-side.
options.localestringoptionalWhich locale a localized field (see the project's Settings) resolves to - the requested locale's value if present, else the project's default locale's. Omit to just get the default locale directly; every localized field's raw { [locale]: value } map is always resolved down to a plain value either way, so a project with no locales configured needs nothing extra here at all.
options.orderBy{ direction?: "asc" | "desc"; field: Extract<keyof T, string> | string }[]optionalOne or more sort terms, applied in order; direction defaults to "asc".
options.select(Extract<keyof T, string> | string)[]optionalOnly return these fields - dotted paths reach into nested object fields, e.g. "duration.start". Omit for the full document.
options.skipnumberoptional
options.where{ [K in keyof T]?: WhereValue<T[K]> }optionalFilters, ANDed together.

Returns: Promise<PublishedDocument<T>[]>

fetchOne

A single published document by id - null if it doesn't exist or has never been published.

fetchOne<T>(type: string, id: string, options?: FetchOneOptions): Promise<PublishedDocument<T> | null>
typestringrequired
idstringrequired
options.includestring[]optional
options.localestringoptional

Returns: Promise<PublishedDocument<T> | null>

fetchSingleton

The published version of a singleton document type - null if it's never been published.

fetchSingleton<T>(type: string, options?: FetchOneOptions): Promise<PublishedDocument<T> | null>
typestringrequired
options.includestring[]optional
options.localestringoptional

Returns: Promise<PublishedDocument<T> | null>

publish

Publishes a document's current draft. Requires token.

publish<T>(type: string, id: string): Promise<Document<T>>
typestringrequired
idstringrequired

Returns: Promise<Document<T>>

unpublish

Unpublishes a document - it keeps its draft, but stops being served by the read methods above. Requires token.

unpublish<T>(type: string, id: string): Promise<Document<T>>
typestringrequired
idstringrequired

Returns: Promise<Document<T>>

update

Overwrites a document's draft data. Requires token.

update<T>(type: string, id: string, data: T): Promise<Document<T>>
typestringrequired
idstringrequired
dataTrequired

Returns: Promise<Document<T>>

uploadAsset

Uploads a file as a new asset - use its id as an image/images field's { assetId }. Requires token.

uploadAsset(file: Blob, filename: string): Promise<UploadedAsset>
fileBlobrequired
filenamestringrequired

Returns: Promise<UploadedAsset>

CairnPreviewClient

Returned by createPreviewClient().

fetch

Documents of a type in the session's dataset, at whichever of draft/published the session's view says - with the same filter/select/include/order/pagination options as CairnClient.fetch.

fetch<T>(type: string, options?: FetchOptions<T>): Promise<PreviewDocument<T>[]>
typestringrequired
options.includestring[]optionalDocument type identifiers to resolve inline wherever referenced. Resolution traverses nested objects/arrays but follows one reference hop. Top-level reference field names are also accepted for compatibility.
options.limitnumberoptionalDefaults to 100, capped at 200 server-side.
options.localestringoptionalWhich locale a localized field (see the project's Settings) resolves to - the requested locale's value if present, else the project's default locale's. Omit to just get the default locale directly; every localized field's raw { [locale]: value } map is always resolved down to a plain value either way, so a project with no locales configured needs nothing extra here at all.
options.orderBy{ direction?: "asc" | "desc"; field: Extract<keyof T, string> | string }[]optionalOne or more sort terms, applied in order; direction defaults to "asc".
options.select(Extract<keyof T, string> | string)[]optionalOnly return these fields - dotted paths reach into nested object fields, e.g. "duration.start". Omit for the full document.
options.skipnumberoptional
options.where{ [K in keyof T]?: WhereValue<T[K]> }optionalFilters, ANDed together.

Returns: Promise<PreviewDocument<T>[]>

fetchOne

A single document by id, at the session's view - null if no document with that id/type exists in the session's dataset at all (not merely unpublished - see PreviewDocument's own doc comment for that case).

fetchOne<T>(type: string, id: string, options?: FetchOneOptions): Promise<PreviewDocument<T> | null>
typestringrequired
idstringrequired
options.includestring[]optional
options.localestringoptional

Returns: Promise<PreviewDocument<T> | null>

fetchSingleton

A singleton type's one document, auto-created server-side on first preview the same way the Dashboard's own editor does.

fetchSingleton<T>(type: string, options?: FetchOneOptions): Promise<PreviewDocument<T> | null>
typestringrequired
options.includestring[]optional
options.localestringoptional

Returns: Promise<PreviewDocument<T> | null>

resolve

Resolves and validates the session server-side (it can't be checked client-side - verifying it needs a DB lookup that never leaves apps/api). Throws if the session is missing, invalid, expired, or revoked. Call this first, before rendering anything, the same way the preview-route template does to decide where to redirect.

resolve(): Promise<PreviewResolution>

Returns: Promise<PreviewResolution>

Types

CairnClientConfig

apiUrlstringoptionalOverride for local development; defaults to the hosted cairn API.
datasetstringoptionalWhich dataset to read from and write to. Reads default to the project's default dataset as the API resolves it (including any delivery route configured for it); writes default to "production". Point this at another dataset's slug - e.g. a separate staging dataset - to serve a site from it. A *branch* isn't publicly readable by its slug; preview a branch through a preview session, or route a live dataset to it.
projectIdstringrequiredThe project's slug (see apps/api's auto-generated slugs) - visible in the dashboard's URL (app.cairn.press/<slug>/...) once the project is open.
tokenstringoptionalA project API token (see the Dashboard's "API tokens" section), for the write methods below - create/update/delete/publish/ unpublish. The read methods (fetch/fetchOne/fetchSingleton) never need one; they only ever read already-public, published content, the same as this client's image() URLs.

CairnPreviewClientConfig

apiUrlstringoptionalOverride for local development; defaults to the hosted cairn API.
previewTokenstringrequiredA preview *session* token - what createPreviewHandler (see packages/client/src/next.ts) stores in its HttpOnly cookie after exchanging the single-use grant from the Dashboard's "Preview" action URL param. Not the grant itself, and not something most integrating code mints or handles directly - the preview-route template is normally the only place this ever gets read from a cookie and passed in. Short-lived (~2h) and revocable; a client built with an expired or revoked session throws on first use, not at construction. This is the same token createPreviewImageUrl (see image.ts) needs to build authorized asset URLs - the two are separate calls since not every page needs images.
projectIdstringrequired

DeliveredDocument

A delivered document from either API - _publishedAt is null only for a never-published draft seen in preview. What @cairn-cms/client/next's createClient returns, since one call can be served by either side.

type DeliveredDocument<T> = PreviewDocument<T>

Document

A document as the write methods see it - the Dashboard-internal CRUD shape, not the delivery one PublishedDocument/PreviewDocument use. T's fields are the current draft, flat at the top level; the published snapshot can't also be flat (it's a second, coexisting copy of the same field names), so it nests under _published instead - null if this document has never been published. Nested field values (reference/ image/video pointers) are the raw stored shape, untagged - unlike delivery, the Dashboard already knows every field's type statically.

type Document<T> = T & { _createdAt: string; _id: string; _key: string; _modified: boolean; _published: T | null; _publishedAt: string | null; _rank: string | null; _singletonKey: string | null; _sourceDocumentId: string | null; _status: string; _updatedAt: string; _versionCount: number }

FetchOneOptions

The subset of FetchOptions fetchOne/fetchSingleton accept.

includestring[]optional
localestringoptional

FetchOptions

Options for fetch() - filters (ANDed together), field selection, one one-hop reference resolution, sorting, and pagination. Compiles down to the query params apps/api's /published/types/:type/documents route accepts (see packages/db/src/publishedQuery.ts) - this just spares callers from hand-building that query string themselves.

includestring[]optionalDocument type identifiers to resolve inline wherever referenced. Resolution traverses nested objects/arrays but follows one reference hop. Top-level reference field names are also accepted for compatibility.
limitnumberoptionalDefaults to 100, capped at 200 server-side.
localestringoptionalWhich locale a localized field (see the project's Settings) resolves to - the requested locale's value if present, else the project's default locale's. Omit to just get the default locale directly; every localized field's raw { [locale]: value } map is always resolved down to a plain value either way, so a project with no locales configured needs nothing extra here at all.
orderBy{ direction?: "asc" | "desc"; field: Extract<keyof T, string> | string }[]optionalOne or more sort terms, applied in order; direction defaults to "asc".
select(Extract<keyof T, string> | string)[]optionalOnly return these fields - dotted paths reach into nested object fields, e.g. "duration.start". Omit for the full document.
skipnumberoptional
where{ [K in keyof T]?: WhereValue<T[K]> }optionalFilters, ANDed together.

ImageFit

type ImageFit = "cover" | "contain" | "fill" | "inside" | "outside" | "scale-down"

ImageFormat

type ImageFormat = "webp" | "avif" | "jpeg" | "png"

ImageSize

Either a named preset, or explicit pixel dimensions (h optional - omit to scale proportionally).

type ImageSize = ImageSizePreset | { h?: number; w: number }

ImageSizePreset

A fixed, named width - matches apps/api's IMAGE_SIZE_PRESETS. Prefer this over an explicit { w, h } where you can: Cloudflare bills per *unique* (image, transform) combination per month, so a caller-chosen arbitrary width makes cost scale with however many distinct sizes get requested across every project on the platform. A preset means the same source image only ever produces one of these variants, no matter who asks - and it's the only form the API can serve from a pre-generated file instead of a live (billed) transform, see apps/api's asset route.

type ImageSizePreset = "blur" | "thumbnail" | "small" | "medium" | "large"

ImageUrlOptions

Transform options for imageUrl() - all optional, same query params apps/api's asset route accepts.

fitImageFitoptional
formatImageFormatoptional
qualitynumberoptional
sizeImageSizeoptionalA named preset (see ImageSizePreset), or explicit { w, h? } pixel dimensions for the rare case a preset doesn't fit.

ImageValue

What an image field's value looks like in document data - see @cairn-cms/schema's validator.

assetIdstringrequired
filenamestringoptional

PortableTextBlock

_keystringrequired
_type"block"required
childrenPortableTextSpan[]required
levelnumberoptional
listItemPortableTextListItemoptional
markDefsPortableTextMarkDef[]required
stylePortableTextBlockStylerequired

PortableTextBlockStyle

type PortableTextBlockStyle = "normal" | "h1" | "h2" | "h3" | "h4" | "blockquote"

PortableTextDecorator

type PortableTextDecorator = "strong" | "em" | "underline" | "strike-through" | "code"

PortableTextHtmlComponents

Per-node renderers toHTML falls back to its plain-tag defaults when unset - matching @portabletext/to-html's components shape, so swapping in e.g. Tailwind classes or custom tags per block/mark type works the same way it does with that package.

blockPartial<Record<PortableTextBlockStyle, (children: string) => string>>optional
listPartial<Record<PortableTextListItem, (children: string) => string>>optional
listItemPartial<Record<PortableTextListItem, (children: string) => string>>optional
marksPartial<Record<PortableTextDecorator, (children: string) => string>> & { link?: (children: string, href: string) => string }optional

PortableTextListItem

type PortableTextListItem = "bullet" | "number"

PortableTextMarkDef

_keystringrequired
_type"link"required
hrefstringrequired

PortableTextSpan

Portable Text types and parsing - the frontend counterpart to a richText field (see @cairn-cms/schema's PortableTextBlock, which this mirrors). Deliberately redefined here rather than imported from @cairn-cms/schema: @cairn-cms/client has no dependency on @cairn-cms/schema (it's meant to be a light, standalone package any frontend can install, the same reason image.ts's createImageUrl doesn't depend on it either), and this shape is a stable data contract either way.

_keystringrequired
_type"span"required
marksstring[]optional
textstringrequired

PreviewDocument

A document as a preview client sees it (ROADMAP.md §4) - T's fields are flat at the top level, sourced from whichever of draft or published the preview token's view says. Unlike PublishedDocument, never "not found" for an existing document just because it isn't live - that's the whole point of preview.

The one case this type can't fully capture: view: "published" on a document that's never actually been published has nothing to show, so T's fields are simply absent from the response at runtime (only _id/ _key/_publishedAt are present) despite this type saying they're there - check _publishedAt for null first if a project might preview unpublished content this way.

type PreviewDocument<T> = T & { _id: string; _key: string; _publishedAt: string | null }

PreviewImageUrlConfig

apiUrlstringoptionalOverride for local development; defaults to the hosted cairn API.
previewTokenstringrequiredThe same preview *session* token createPreviewClient uses - see CairnPreviewClientConfig.previewToken's doc comment in client.ts.
projectIdstringrequired
secureCdnUrlstringoptionalOverride for local development; defaults to the hosted secure/preview asset CDN.

PreviewImageUrlOptions

imageUrl()'s only option in preview mode - a named preset, or omit for the original. Unlike createImageUrl, there's no API-route fallback for anything else (no apiUrl transform path exists on the preview side) - fit/format/quality/explicit dimensions simply aren't offered here.

sizeImageSizePresetoptional

PreviewResolution

What GET .../preview/resolve reports once a token verifies - see apps/api's routes/preview.ts. documentType/documentId are null for a token minted without one (e.g. previewing a whole dataset/branch rather than jumping to one document).

datasetIdstringrequired
documentIdstring | nullrequired
documentTypestring | nullrequired
view"draft" | "published"required

PublishedDocument

A published document - T's fields sit flat at the top level alongside this fixed, underscore-prefixed metadata (never colliding with a content field name - the schema rejects a leading underscore on one). Matches apps/api's delivery envelope exactly; see packages/db's shapeDeliveryData/buildEnvelope for where this shape comes from.

_id is the document *type's* API identifier (e.g. "post") - the same for every document of that type; _key is this specific document's own unique instance id. Mirrors how an array/blockRef item's own _id/_key pair works.

type PublishedDocument<T> = T & { _id: string; _key: string; _publishedAt: string }

UploadedAsset

What apps/api's POST /assets returns - id is what an image field's { assetId } points at.

filenamestringrequired
heightnumber | nullrequired
idstringrequired
mimeTypestringrequired
sizenumberrequired
widthnumber | nullrequired

WhereOp

A single filter's comparison operator, matching apps/api's fields.<path>[<op>] query param.

type WhereOp<V> = { eq: V } | { ne: V } | { gt: V } | { gte: V } | { lt: V } | { lte: V } | { in: V[] } | { match: string }

WhereValue

A bare value means equality - { category: "edits" } is shorthand for { category: { eq: "edits" } }, same convention as Prisma/Drizzle's own where shape.

type WhereValue<V> = V | WhereOp<V>