> For AI agents: the complete Sanity documentation index is available at [https://www.sanity.io/docs/llms.txt](https://www.sanity.io/docs/llms.txt).

# 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**

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

**pnpm**

```shell
pnpm add @sanity/sdk@^3.11.0 rxjs@^7.8.2
```

**yarn**

```shell
yarn add @sanity/sdk@^3.11.0 rxjs@^7.8.2
```

**bun**

```shell
bun add @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:

```typescript
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](https://www.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:

**CLI**

```sh
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](https://www.sanity.io/docs/collaboration/sdk-overview).

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:

```typescript
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.

```typescript
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:

```typescript
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:

```typescript
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:

```typescript
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:

```typescript
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](https://www.sanity.io/docs/collaboration/sdk-write-comments).

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:

```typescript
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](https://www.sanity.io/docs/collaboration/sdk-overview), for the full signatures of the read state and the actions.
- [Levels of abstraction in the comment stack](https://www.sanity.io/docs/collaboration/levels-of-abstraction), for where this sits among the other ways in.

