> 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).

# Levels of abstraction in the comment stack

The two ladders of the collaboration stack, what renders and where data comes from, and what each rung trades.

The collaboration stack is two independent ladders: one for what renders, one for where the data comes from. Every rung down trades work done for you against control over what happens, and you pick a rung per ladder rather than a level for the whole app. A panel built from the default components can read through the raw HTTP API, and a UI of your own can still take the SDK's optimistic writes.

This page is the map. It says what each rung gives you, what you take on by standing on it, and when to step down.

```mermaid
flowchart LR
  subgraph ui [What renders]
    panel["The panel"] --> defaults["Default components"] --> primitives["Primitives"] --> own["Your own UI"]
  end
  subgraph data [Where data comes from]
    hooks["SDK hooks"] --> actions["SDK actions"] --> client["Sanity client"] --> http["HTTP API"]
  end
  own -.-> hooks
```

## The UI ladder

Each rung renders the same as the one above it. Dropping one keeps the skeletons, the accessible names, the reaction grouping, and the action list. Only the arrangement becomes yours.

### The panel

One component and a handle. `SDKCommentsPanel` reads a document's threads, filters them by status, wires every write a card offers, and puts an *Add a new comment thread* action under each field heading: the whole of a comments sidebar, assembled. `compose` adds a composer at the top of the list for a field that has no threads yet, and so no heading of its own to carry one.

You take on the provider around it, `SDKCommentsProvider`, which supplies identity and holds the selection, and `onCopyLink`, passed through `threadProps`, if you want shareable links, since only your app knows what its URLs look like. You also take on a `Suspense` boundary and an error boundary around both: `SDKCommentsPanel` suspends until the threads load, and `SDKCommentsProvider` suspends until the current user is known and throws for a viewer who is logged out.

Step down to `CommentsPanel` when the data is yours rather than the SDK's, and to the [default components](https://www.sanity.io/docs/collaboration/levels-of-abstraction) when the arrangement stops fitting.

```tsx
<SDKCommentsProvider>
  <SDKCommentsPanel {...handle} compose={{fieldPath: 'title'}} />
</SDKCommentsProvider>
```

### Default components

The panel's own parts, so you arrange them: grouping, sorting, collapsible replies, the mention picker, the empty state, and the scroll targets deep links need. `SDKCommentsProvider` resolves the current user and every author from the SDK, so identity is still handled. Mention options are still yours; see [Enable mentions](https://www.sanity.io/docs/collaboration/enable-mentions).

You take on the layout around it and the callbacks you choose to pass. An action appears only when you pass its handler, so the panel's capabilities are yours to decide.

Step down when the arrangement stops fitting, not when a detail does. Most details are the theme, a prop, or a label.

```tsx
const threadCallbacks = useSDKThreadCallbacks(handle)

return (
  <SDKCommentsProvider>
    <CommentList threads={threads}>
      {(thread) => (
        <CommentThread
          key={thread.threadId}
          thread={thread}
          {...threadCallbacks(thread)}
        />
      )}
    </CommentList>
  </SDKCommentsProvider>
)
```

### Primitives

The pieces a default comment is assembled from, exported individually: `CommentHeader`, `CommentMessageContent`, `CommentActionBar`, `CommentTimestamp`, `CommentReactions`, `CommentContentSnapshot`, `UserAvatar`, `UserName`, `MentionChip`, `AddReactionButton`, `CommentErrorNotice`, and `DeleteCommentDialog`, plus the hooks that carry the behavior, including `useCommentUser`, `useSelectionProps`, and `useNavigableComment`. Everything on this rung comes from `@sanity/collaboration/ui`, the entry point with no SDK in it.

You take on the arrangement, the states a comment can be in, and the wiring between them. The plain `CommentsProvider` also means identity is yours: pass `currentUser`, `getUser`, and the mention options yourself.

Step down when you need a layout the defaults cannot express, such as a comment that leads with a document preview or a card with your own chrome. [Build a custom comment](https://www.sanity.io/docs/collaboration/build-a-custom-comment) walks the whole path.

```tsx
const author = useCommentUser(comment.authorId)
const {resolveCapabilities} = useCommentsContext()
const capabilities = resolveCapabilities(comment)

return (
  <Card padding={3}>
    <CommentHeader
      author={author.user}
      authorLoading={author.loading}
      createdAt={comment.createdAt}
    />
    <CommentMessageContent message={comment.message} />
    <CommentActionBar
      actions={commentActions({comment, capabilities, onEdit, onDelete})}
    />
  </Card>
)
```

### Your own UI

No component from this library. You render comment data with whatever you already use.

You take on everything the library was doing: mention rendering inside Portable Text messages, reaction grouping, relative timestamps, the skeleton and error states, keyboard selection, and the `data-*` attributes deep linking relies on. That is a lot to rebuild, which is why this rung is usually a deliberate choice rather than a fallback.

Step down when comments appear somewhere a comment card would look wrong, such as an activity feed, a notification, or a count in a table.

A message is Portable Text, so rendering one means bringing your own renderer. `@portabletext/react` covers the text; mentions are a custom object type you render yourself.

```tsx
const {threads} = useCommentThreads({documentId, documentType: 'article'})

return (
  <ul>
    {threads.map((thread) => (
      <li key={thread.threadId}>
        <PortableText value={thread.parentComment.message} />
        <span>{thread.commentsCount} in thread</span>
      </li>
    ))}
  </ul>
)
```

## The data ladder

Each rung down is closer to the wire. The first step costs you only the subscription management React was doing. The step after that is the expensive one: leaving the SDK means leaving the store, and with it optimistic writes, caching, and thread building.

### SDK hooks

The hooks from `@sanity/sdk-react/collaboration`, including `useCommentThreads`, `useCommentsQuery`, and `useCommentActions`. Reads stay live over a listener and suspend on first load. `useCommentThreads` groups comments into threads; `useCommentsQuery` returns them flat, replies included. Writes apply locally before the server confirms them. A failed edit, delete, status change, or reaction rolls back. A failed create or reply stays on screen carrying `state.createError`, so you can retry it with the same `commentId`.

You take on the Suspense boundary and the error boundary, since the first read throws rather than returning a loading flag. A failure after that arrives as `error` on the result instead.

This is the rung to stay on. Everything in [Comment data flow](https://www.sanity.io/docs/collaboration/data-flow) assumes it.

```tsx
const {createComment} = useCommentActions()

await createComment({
  documentId,
  documentType: 'article',
  fieldPath: 'title',
  message: [
    {
      _type: 'block',
      _key: 'b1',
      children: [{_type: 'span', _key: 's1', text: 'Needs work'}],
    },
  ],
})
```

### SDK actions

The same writes and reads as functions rather than hooks: `createComment`, `replyToComment`, `updateComment`, `updateCommentAnchor`, `setCommentStatus`, `removeComment`, `addReaction`, and `removeReaction`, plus read functions, including `getCommentThreadsState`, `getCommentsQueryState`, `resolveCommentThreads`, and `resolveCommentsQuery`. Each takes a `SanityInstance` as its first argument.

They share the store the hooks read, so optimistic state, listeners, and thread building all still apply. You take on subscribing and unsubscribing yourself, which the hooks were doing.

Step down when the code is not a React component: a server action, a script, a worker, or a framework that is not React.

```typescript
import {createComment, resolveCommentThreads} from '@sanity/sdk/collaboration'

const threads = await resolveCommentThreads(instance, {
  documentId,
  documentType: 'article',
})

await createComment(instance, {
  documentId,
  documentType: 'article',
  fieldPath,
  message,
})
```

### Sanity client

`client.collaboration.comments` on `@sanity/client` 8.2.0 or later: `fetch`, `listen`, `create`, `update`, `delete`, `addReaction`, and `removeReaction`. Reads are GROQ over comment documents, so the filter is yours to write, and `_type == "sanity.comment"` is worth including because the organization store holds more than comments.

You lose the store. Nothing is optimistic, nothing is cached, and comments come back as flat documents rather than threads, so pairing replies with parents by `threadId` becomes your job.

Step down when you already have a configured client and no SDK instance, such as a backend service.

> [!WARNING]
> Comments are served on `vX`, an unstable version. It can change without a deprecation window, and code written against it needs rechecking at every client release. Move to a dated version once one exists.

```typescript
import {createClient} from '@sanity/client'

const client = createClient({
  projectId: 'YOUR_PROJECT_ID',
  dataset: 'production',
  apiVersion: 'vX',
  collaboration: {organizationId: 'YOUR_ORGANIZATION_ID'},
  useCdn: false,
  token: process.env.SANITY_TOKEN,
})

const comments = await client.collaboration.comments.fetch(
  '*[_type == "sanity.comment" && target.document._ref == $ref] | order(_createdAt desc)',
  {ref: client.collaboration.comments.getTargetDocumentRef(documentId)},
)
```

This example authenticates with a token, so run it on a server. Never ship a token to the browser.

Without `collaboration.organizationId`, every method that sends a request throws ``collaboration.organizationId` must be configured to use collaboration comments`. Comments are stored per organization and reached through the collaboration API, and nothing in a project and dataset tells the client which organization owns them.

### HTTP API

The endpoints every other rung is built on, served from the project hostname:

| Method | Path |
| --- | --- |
| `POST` | `/vX/collaboration/comments` |
| `PATCH` `DELETE` | `/vX/collaboration/comments/:id` |
| `POST` | `/vX/collaboration/comments/:id/reactions` |
| `DELETE` | `/vX/collaboration/comments/:id/reactions/:shortName` |
| `GET` `POST` | `/vX/collaboration/comments/query` |
| `GET` | `/vX/collaboration/comments/listen` |

Every request carries a bearer token and three query parameters that say which comments it addresses: `organizationId`, `resourceType=dataset`, and `resourceId` as `{projectId}.{dataset}`. For Canvas comments, send `resourceType=canvas` with the canvas ID as `resourceId`, to `https://api.sanity.io` rather than the project hostname.

You take on everything: authentication, retries, reconnecting the event stream, and reconciling what it sends you.

Step down when the language is not JavaScript.

> [!WARNING]
> `vX` is unstable. Prefer the client, which tracks the endpoints for you.

**CLI**

```sh
curl -X POST \
  'https://YOUR_PROJECT_ID.api.sanity.io/vX/collaboration/comments?organizationId=YOUR_ORGANIZATION_ID&resourceType=dataset&resourceId=YOUR_PROJECT_ID.production' \
  -H "Authorization: Bearer $SANITY_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{
    "message": [{"_type": "block", "children": [{"_type": "span", "text": "Needs work"}]}],
    "target": {"documentId": "article-1", "documentType": "article", "path": "title"}
  }'
```

Replace `YOUR_PROJECT_ID` with your project ID and `YOUR_ORGANIZATION_ID` with the ID of the organization that owns it. Both are in [sanity.io/manage](https://www.sanity.io/manage).

## Where to go next

- [Build a custom comment](https://www.sanity.io/docs/collaboration/build-a-custom-comment) for the UI ladder's middle rung, step by step.
- [Comment data flow](https://www.sanity.io/docs/collaboration/data-flow) for how reads, writes, and callbacks meet on the hooks rung.
- The [SDK guides](https://www.sanity.io/docs/collaboration/sdk-overview) for the hooks and actions task by task, and their [Reference](https://www.sanity.io/docs/collaboration/sdk-overview) section for the full signatures.

