Querying

@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 fetch and WebSocket APIs. 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

OptionTypeRequiredDefaultDescription
apiUrlstringYes--Base URL of the CellCMS API.
projectstringNo--Project slug.
datasetstringNo"production"Dataset name.
tokenstringNo--JWT access token or API token.
timeoutnumberNo30000Request 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

MethodDescription
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:

OptionTypeDescription
includeResultbooleanInclude the full document in events.
includePreviousRevisionbooleanInclude the previous document state.

ListenEvent fields:

FieldTypeDescription
typestring"mutation", "welcome", or "reconnect".
documentIdstringID of the affected document (mutation events only).
transitionstring"appear", "update", or "disappear".
resultTThe 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:

FieldTypeDescription
totalnumberTotal documents processed.
creatednumberNew documents created.
replacednumberExisting documents replaced.
skippednumberDocuments skipped.
errorsArray<{ 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:

PropertyTypeDescription
messagestringError message from the API, or "HTTP <status>".
statusCodenumberHTTP status code (e.g. 401, 404, 500).
bodyobjectFull 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>;
}