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.
This diagram could not be rendered.
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 when the arrangement stops fitting.
<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.
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.
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 walks the whole path.
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.
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 assumes it.
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.
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.
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.
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 |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
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.
vX is unstable. Prefer the client, which tracks the endpoints for you.
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.
Where to go next
- Build a custom comment for the UI ladder's middle rung, step by step.
- Comment data flow for how reads, writes, and callbacks meet on the hooks rung.
- The SDK guides for the hooks and actions task by task, and their Reference section for the full signatures.
Was this page helpful?
Feedback submitted, thanks for helping us improve the docs. Have you tried these alternatives?