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.0andrxjs^7.8.2, installed as direct dependencies.@sanity/sdkdepends onrxjs, 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"inpackage.json, or.mtsfiles.@sanity/sdkships only ES modules, and the examples use top-levelawait.
Install both packages:
npm install @sanity/sdk@^3.11.0 rxjs@^7.8.2pnpm add @sanity/sdk@^3.11.0 rxjs@^7.8.2yarn add @sanity/sdk@^3.11.0 rxjs@^7.8.2bun add @sanity/sdk@^3.11.0 rxjs@^7.8.2Create 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: '<your-project-id>',
dataset: 'production',
auth: {token},
})Replace <your-project-id> 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.tsThe 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
- Reference, for the full signatures of the read state and the actions.
- Levels of abstraction in the comment stack, for where this sits among the other ways in.
Was this page helpful?
Feedback submitted, thanks for helping us improve the docs. Have you tried these alternatives?