@cellcms/client SDK Reference
The @cellcms/client package is a drop-in replacement for @sanity/client. It provides a
typed, promise-based interface for querying, mutating, and subscribing to CellCMS datasets.
Installation
npm install @cellcms/client
Peer dependency — the package uses the global
fetchandWebSocketAPIs. Node 18+ and all modern browsers are supported out of the box.
Configuration
Import createClient and pass a ClientConfig object:
import { createClient } from "@cellcms/client";
const client = createClient({
apiUrl: "https://api.cellcms.com",
project: "my-project",
dataset: "production",
token: process.env.CELLCMS_TOKEN,
timeout: 30000,
});
ClientConfig options
| Option | Type | Required | Default | Description |
|---|---|---|---|---|
apiUrl | string | Yes | -- | Base URL of the CellCMS API. |
project | string | No | -- | Project slug. |
dataset | string | No | "production" | Dataset name. |
token | string | No | -- | JWT access token or API token. |
timeout | number | No | 30000 | Request timeout in milliseconds. |
createClient()
Factory function that returns a CellCmsClient instance.
import { createClient, type ClientConfig } from "@cellcms/client";
const config: ClientConfig = {
apiUrl: "https://api.cellcms.com",
dataset: "production",
token: "sk_live_...",
};
const client = createClient(config);
If you are migrating from Sanity, replace import { createClient } from "@sanity/client"
with the CellCMS import. The API surface is intentionally compatible.
Querying
fetch()
Execute a GROQ query against the dataset. This is the primary query method, equivalent to
sanityClient.fetch().
// Signature
client.fetch<T>(query: string, params?: Record<string, unknown>): Promise<T>
Example -- list published articles:
const articles = await client.fetch<Article[]>(
`*[_type == "article" && published == true] | order(publishedAt desc) [0...10] {
_id,
title,
slug,
publishedAt
}`
);
Example -- parameterised query:
const article = await client.fetch<Article>(
`*[_type == "article" && slug.current == $slug][0]`,
{ slug: "hello-world" }
);
getDocument()
Fetch a single document by its _id. Returns null when the document does not exist
(404 responses are caught internally).
// Signature
client.getDocument<T>(id: string): Promise<T | null>
const page = await client.getDocument<Page>("page-about");
if (!page) {
console.log("Page not found");
}
getDocuments()
Fetch multiple documents by ID. Resolves to an array of the same length as the input; each
element is the document or null if not found.
// Signature
client.getDocuments<T>(ids: string[]): Promise<(T | null)[]>
const [home, about, contact] = await client.getDocuments<Page>([
"page-home",
"page-about",
"page-contact",
]);
Creating documents
All create methods send a single mutation and return a MutationResult containing
transactionId and results.
create()
Create a new document. CellCMS assigns an _id if none is provided.
const result = await client.create({
_type: "article",
title: "Getting started with CellCMS",
slug: { _type: "slug", current: "getting-started" },
});
console.log(result.transactionId);
createOrReplace()
Create a document, or fully replace the existing document with the same _id.
The document must include an _id.
await client.createOrReplace({
_id: "singleton-settings",
_type: "siteSettings",
title: "My Site",
description: "Updated site description",
});
createIfNotExists()
Create a document only when no document with the given _id exists. Useful for
idempotent seeding and migrations.
await client.createIfNotExists({
_id: "singleton-settings",
_type: "siteSettings",
title: "My Site",
description: "Default description",
});
Patching documents
The patch() method returns a fluent PatchBuilder. Chain operations and call
commit() to execute the patch as a single mutation.
PatchBuilder methods
| Method | Description |
|---|---|
set(attrs) | Set (overwrite) fields. |
setIfMissing(attrs) | Set fields only if they are not already present. |
unset(paths) | Remove fields by path. |
inc(attrs) | Increment numeric fields. |
dec(attrs) | Decrement numeric fields. |
ifRevisionId(rev) | Conditional patch -- only apply if revision matches. |
commit() | Execute the patch and return MutationResult. |
Examples
Set a single field:
await client
.patch("article-123")
.set({ title: "Updated title" })
.commit();
Combine multiple operations:
await client
.patch("article-123")
.set({ title: "New title", updatedAt: new Date().toISOString() })
.setIfMissing({ views: 0 })
.inc({ views: 1 })
.unset(["legacyField", "tempFlag"])
.commit();
Optimistic locking with ifRevisionId:
const doc = await client.getDocument<Article>("article-123");
if (doc) {
await client
.patch("article-123")
.ifRevisionId(doc._rev)
.set({ title: "Safe update" })
.commit();
}
Deleting documents
delete()
Delete a single document by _id. Returns a MutationResult.
await client.delete("article-123");
To delete multiple documents in one call, use mutate() (see below).
Batch mutations
mutate()
Execute an array of mutations atomically within a single transaction.
// Signature
client.mutate(request: MutationRequest): Promise<MutationResult>
const result = await client.mutate({
mutations: [
{ create: { _type: "tag", name: "TypeScript" } },
{ create: { _type: "tag", name: "React" } },
{ patch: { id: "article-123", set: { tags: ["typescript", "react"] } } },
{ delete: { id: "draft.article-old" } },
],
});
console.log(`Transaction: ${result.transactionId}`);
All mutations in the array either succeed or fail together.
Real-time subscriptions
listen()
Open a WebSocket connection and receive events for documents matching a GROQ filter.
Returns an object with a subscribe() method (observable-like pattern).
// Signature
client.listen<T>(
query: string,
params?: Record<string, unknown>,
options?: ListenOptions
): { subscribe(callback): { unsubscribe() } }
ListenOptions:
| Option | Type | Description |
|---|---|---|
includeResult | boolean | Include the full document in events. |
includePreviousRevision | boolean | Include the previous document state. |
ListenEvent fields:
| Field | Type | Description |
|---|---|---|
type | string | "mutation", "welcome", or "reconnect". |
documentId | string | ID of the affected document (mutation events only). |
transition | string | "appear", "update", or "disappear". |
result | T | The document after the mutation (when includeResult is true). |
Example:
const subscription = client
.listen<Article>(`*[_type == "article"]`)
.subscribe((event) => {
if (event.type === "welcome") {
console.log("Connected to real-time feed");
return;
}
if (event.type === "reconnect") {
console.log("Reconnecting...");
return;
}
console.log(`${event.transition}: ${event.documentId}`);
});
// Later, clean up:
subscription.unsubscribe();
The client automatically reconnects after 3 seconds when the WebSocket closes unexpectedly.
Data migration
export()
Export documents from the dataset as an NDJSON string. The export timeout is 10x the
configured timeout to accommodate large datasets.
// Signature
client.export(options?: { types?: string[] }): Promise<string>
// Export everything
const allData = await client.export();
// Export specific types only
const articles = await client.export({ types: ["article", "author"] });
// Write to file (Node.js)
import { writeFileSync } from "fs";
writeFileSync("backup.ndjson", allData);
import()
Import documents from an NDJSON string. The format is compatible with Sanity export files, making it straightforward to migrate data.
// Signature
client.import(
ndjson: string,
options?: { replace?: boolean }
): Promise<ImportResult>
ImportResult fields:
| Field | Type | Description |
|---|---|---|
total | number | Total documents processed. |
created | number | New documents created. |
replaced | number | Existing documents replaced. |
skipped | number | Documents skipped. |
errors | Array<{ line: number; error: string }> | Per-line errors, if any. |
import { readFileSync } from "fs";
const ndjson = readFileSync("backup.ndjson", "utf-8");
// Import without replacing existing documents
const result = await client.import(ndjson);
console.log(`Created ${result.created}, skipped ${result.skipped}`);
// Import and replace any existing documents with the same _id
const result2 = await client.import(ndjson, { replace: true });
console.log(`Replaced ${result2.replaced}`);
Authentication
login()
Authenticate with email and password. On success, the returned JWT is automatically stored on the client instance, so subsequent requests are authenticated.
// Signature
client.login(email: string, password: string): Promise<LoginResponse>
const client = createClient({
apiUrl: "https://api.cellcms.com",
});
const response = await client.login("editor@example.com", "s3cur3-p4ss");
console.log(`Logged in, token: ${response.accessToken}`);
// The client is now authenticated -- no need to set the token manually.
await client.create({ _type: "article", title: "My post" });
For server-side and CI pipelines, prefer passing a long-lived API token via the token
config option instead of calling login().
Error handling
When the API returns a non-2xx response, the client throws an error with the following properties:
| Property | Type | Description |
|---|---|---|
message | string | Error message from the API, or "HTTP <status>". |
statusCode | number | HTTP status code (e.g. 401, 404, 500). |
body | object | Full parsed error response body, when available. |
try {
await client.fetch(`*[_type == "secret"]`);
} catch (err: any) {
if (err.statusCode === 401) {
console.error("Not authenticated. Please check your token.");
} else if (err.statusCode === 403) {
console.error("Insufficient permissions.");
} else {
console.error(`API error ${err.statusCode}: ${err.message}`);
}
}
Timeouts throw an AbortError when the configured timeout is exceeded.
TypeScript generics
Every read and write method accepts a generic type parameter so you get full type safety throughout your application.
interface Article {
_id: string;
_type: "article";
_rev: string;
title: string;
slug: { _type: "slug"; current: string };
body: any[];
publishedAt: string;
}
// Query -- returns Article[]
const articles = await client.fetch<Article[]>(
`*[_type == "article"] | order(publishedAt desc)`
);
// Single document -- returns Article | null
const article = await client.getDocument<Article>("article-123");
// Patches use MutationResult (no generic needed)
await client.patch("article-123").set({ title: "Updated" }).commit();
Define your document types in a shared types.ts file and import them wherever you use
the client.
Next.js integration (App Router)
Server Component data fetching
Create a shared client instance and use it in server components with fetch():
// lib/cellcms.ts
import { createClient } from "@cellcms/client";
export const client = createClient({
apiUrl: process.env.CELLCMS_API_URL!,
project: process.env.CELLCMS_PROJECT!,
dataset: "production",
token: process.env.CELLCMS_TOKEN,
});
// app/blog/page.tsx
import { client } from "@/lib/cellcms";
interface Article {
_id: string;
title: string;
slug: { current: string };
publishedAt: string;
}
export default async function BlogPage() {
const articles = await client.fetch<Article[]>(
`*[_type == "article" && published == true] | order(publishedAt desc) {
_id, title, slug, publishedAt
}`
);
return (
<ul>
{articles.map((a) => (
<li key={a._id}>
<a href={`/blog/${a.slug.current}`}>{a.title}</a>
</li>
))}
</ul>
);
}
Dynamic route with generateStaticParams
// app/blog/[slug]/page.tsx
import { client } from "@/lib/cellcms";
export async function generateStaticParams() {
const slugs = await client.fetch<{ slug: string }[]>(
`*[_type == "article"]{ "slug": slug.current }`
);
return slugs.map((s) => ({ slug: s.slug }));
}
export default async function ArticlePage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const article = await client.fetch(
`*[_type == "article" && slug.current == $slug][0]`,
{ slug }
);
if (!article) return <p>Not found</p>;
return <article><h1>{(article as any).title}</h1></article>;
}
Client Component with real-time updates
"use client";
import { useEffect, useState } from "react";
import { createClient } from "@cellcms/client";
const client = createClient({
apiUrl: process.env.NEXT_PUBLIC_CELLCMS_API_URL!,
dataset: "production",
});
export function LiveArticleCount() {
const [count, setCount] = useState<number | null>(null);
useEffect(() => {
client.fetch<number>(`count(*[_type == "article"])`).then(setCount);
const sub = client
.listen(`*[_type == "article"]`)
.subscribe(() => {
client.fetch<number>(`count(*[_type == "article"])`).then(setCount);
});
return () => sub.unsubscribe();
}, []);
return <p>{count !== null ? `${count} articles` : "Loading..."}</p>;
}
Related documentation
- Getting started -- project setup and first query
- Authentication -- token management, roles, and permissions
- API Reference -- REST endpoint details