Collaboration (Beta)

Use the SDK outside React

Read and write comments from plain TypeScript with @sanity/sdk/collaboration: scripts, servers, tests, or other frameworks.

The comment hooks in @sanity/sdk-react/collaboration are a thin layer over @sanity/sdk/collaboration, which has no React in it. This guide reads and writes comments from plain TypeScript: a script, a server, a test, or another framework.

Prerequisites:

  • @sanity/sdk ^3.11.0 and rxjs ^7.8.2, installed as direct dependencies. @sanity/sdk depends on rxjs, but the examples import it themselves, and a package manager with an isolated layout, such as pnpm, only resolves direct dependencies.
  • A token or a signed-in session the SDK can authenticate with.
  • An ES module project: "type": "module" in package.json, or .mts files. @sanity/sdk ships only ES modules, and the examples use top-level await.

Install both packages:

npm install @sanity/sdk@^3.11.0 rxjs@^7.8.2

Create an instance

Every read and write function takes a SanityInstance as its first argument. In React, SanityApp makes one; outside it, make it yourself:

import {createSanityInstance} from '@sanity/sdk'

const token = process.env.SANITY_API_TOKEN
if (!token) {
  throw new Error('Set SANITY_API_TOKEN before running this script.')
}

const instance = createSanityInstance({
  projectId: '',
  dataset: 'production',
  auth: {token},
})

Replace with your project ID from sanity.io/manage.

SANITY_API_TOKEN is an API token read from the environment. Without one, createSanityInstance makes an unauthenticated instance and the first request fails later, so check for it at startup. Keep the token in server or script code, never in code that runs in a browser. To read comments, the token needs read access to the dataset; writes record the user behind it as the author.

To load it from a .env file, pass the file to Node. Node 24 and later run a .ts file directly:

node --env-file=.env index.ts

The organization the comments live in is resolved as it is in an app: the one that owns the project. See Which organization.

Call instance.dispose() when you are done with it, after unsubscribing from every state source you subscribed to.

Read comments once

resolveComments, resolveCommentThreads, and resolveCommentsQuery wait for a list to load and resolve to it:

import {resolveCommentThreads} from '@sanity/sdk/collaboration'

const threads = await resolveCommentThreads(instance, {
  documentId: 'post-123',
  documentType: 'post',
  status: 'open',
})

They take the same options as the hooks, except resourceName, which only resolves inside React, plus an optional signal to abort the wait.

Follow a list as it changes

getCommentsState, getCommentThreadsState, and getCommentsQueryState return a state source: the current value, a way to subscribe to changes, and an observable. The value is undefined until the first snapshot arrives.

import {getCommentThreadsState} from '@sanity/sdk/collaboration'

const threads = getCommentThreadsState(instance, {
  documentId: 'post-123',
  documentType: 'post',
})

const unsubscribe = threads.subscribe(() => {
  const current = threads.getCurrent()
  // `render` is your own function that draws the list.
  if (current) render(current)
})

Subscribing is what opens the connection, and the list is let go a short while after its last subscriber leaves, so keep the subscription for as long as you show the list.

To notice a dropped connection, read the matching error state beside it. getCommentsErrorState covers document reads, and getCommentsQueryErrorState covers queries. Its value is undefined while the list is live. It does not hold the list open on its own, so read it beside a subscribed list:

import {getCommentsErrorState} from '@sanity/sdk/collaboration'

const error = getCommentsErrorState(instance, {
  documentId: 'post-123',
  documentType: 'post',
})
// `showStale` is your own function that marks the list as out of date.
const unsubscribeError = error.subscribe(() =>
  showStale(error.getCurrent() !== undefined),
)

When you stop showing the list, unsubscribe from both before disposing of the instance:

unsubscribeError()
unsubscribe()
instance.dispose()

Wait for the current user

Writes record the current user as the author, and the SDK reads that user synchronously when an action is called. A token makes the instance logged in straight away, but the user behind it arrives in a request of its own, so an action called before then throws Writing a comment requires a logged in user. Wait for it once, before the first write:

import {getCurrentUserState} from '@sanity/sdk'
import {filter, firstValueFrom} from 'rxjs'

await firstValueFrom(
  getCurrentUserState(instance).observable.pipe(filter(Boolean)),
)

createComment, replyToComment, addReaction, and removeReaction need it. Reads do not.

Write with the instance

The actions are the same functions useCommentActions binds, with the instance passed first:

import {createComment, setCommentStatus} from '@sanity/sdk/collaboration'

const comment = await createComment(instance, {
  documentId: 'post-123',
  documentType: 'post',
  fieldPath: 'title',
  message: toMessage('Flagged by the nightly check.'),
})

await setCommentStatus(instance, {commentId: comment.id, status: 'resolved'})

toMessage wraps plain text as Portable Text; see Write a message.

The writes are optimistic here too: a list read from the same instance shows them straight away.

replyToComment needs the parent loaded, which means a read covering it has to be held: subscribe to getCommentThreadsState for as long as you might reply. A resolve* call keeps the list for only a few seconds after it settles, so a reply sent later fails with Cannot reply to comment …: it is not loaded. Read the thread it belongs to first.

Group a flat list into threads

buildCommentThreads groups any flat list of comments the way getCommentThreadsState does. It is a pure function, so it works on a list from anywhere:

import {
  buildCommentThreads,
  resolveCommentsQuery,
} from '@sanity/sdk/collaboration'

const comments = await resolveCommentsQuery(instance, {
  filter: 'status == "open"',
})
const threads = buildCommentThreads(comments)

Pass replies along with their parents. Replies whose parent is missing are dropped.

Next steps

Was this page helpful?

Sanity logosanity.ioAll Systems Operational© Sanity 2026
Change site theme