Collaboration (Beta)

Threads and comments

How comment threads are shaped: one field of one document, a first comment plus replies, and no separate thread record.

A thread hangs off one field of one document and holds the comment that started it plus its replies. There is no separate thread record to create: writing the first comment on a field creates the thread, and the SDK returns the two together. Everything else on a comments surface is a consequence of that shape.

Threads

A thread is what you render one card for. It is what useCommentThreads gives you, already assembled into parent, replies, count, and status.

const thread = {
  threadId: 'thread-9f2c',
  // The field the thread hangs off: 'title', a full path such as
  // 'body[_key=="c1"].content', or '' for a thread on the whole document.
  fieldPath: 'title',
  // The comment that started it.
  parentComment: {
    /* ... */
  },
  // Oldest first.
  replies: [
    {
      /* ... */
    },
  ],
  // The parent plus its replies.
  commentsCount: 3,
  // Resolving the parent resolves the thread.
  status: 'open',
  lastActivityAt: '2026-08-30T09:12:44.000Z',
}

fieldPath is the grouping key everywhere: CommentList groups by it, CommentBadge shows the count you compute by grouping on it, and a new comment names one when it is created.

Comments

A comment carries its author, its message, and the state the UI renders from.

const comment = {
  id: 'comment-4a71',
  createdAt: '2026-08-30T09:04:11.000Z',
  // Absent when the author is recorded on the server rather than the comment.
  authorId: 'user-abc',
  // Portable Text.
  message: [
    {
      /* ... */
    },
  ],
  threadId: 'thread-9f2c',
  // Absent on the comment that starts a thread.
  parentCommentId: 'comment-1f03',
  status: 'open',
  // Set once the message has been rewritten.
  lastEditedAt: '2026-08-30T09:31:02.000Z',
  // Always the published ID, shared by the draft and every version.
  documentId: 'post-123',
  // The exact ID it was written against: published, draft, or version.
  sourceDocumentId: 'drafts.post-123',
  fieldPath: 'title',
  // Inline comments only: the Portable Text the selection covered when the
  // comment was written.
  contentSnapshot: [
    {
      /* ... */
    },
  ],
  reactions: [{shortName: ':+1:', userId: 'user-def', addedAt: '...'}],
  // Local only: set when a create fails, and while it is retried.
  state: undefined,
}

Two fields are worth knowing about before you render anything.

contentSnapshot is the text an inline comment was written about, as it read at the time, which is what lets a comment about since-edited words still make sense. CommentContentSnapshot renders it as a quote, and renders nothing when there is nothing to quote.

state is not on the wire. The SDK sets it when an optimistic create fails and while it is retried, and a createError state is what turns a comment card red and offers a retry. See Comment data flow.

A comment on a run of text inside a Portable Text field carries one more: selection, the words it is about, in a form that survives edits to the text around them. It is what draws a highlight in an editor. For how it works, see Inline comments.

Messages are Portable Text

A comment's message is an array of Portable Text blocks, and a mention is an inline object inside one of them:

const message = [
  {
    _type: 'block',
    _key: 'b1',
    children: [
      {_type: 'span', _key: 's1', text: 'Should this say '},
      {_type: 'mention', _key: 'm1', userId: 'user-def'},
      {_type: 'span', _key: 's2', text: "'s name instead?"},
    ],
  },
]

Two consequences you will meet.

A mention carries a userId and nothing else, so rendering a name means looking the user up. That is what the provider's lookup is for.

A GROQ filter for "mentions me" has to descend into children, because mentions are not blocks. Pass it as the filter option of useCommentsQuery, which adds _type == "sanity.comment" for you and runs it over every comment the organization can read (see Query comments):

count(message[].children[_type == "mention" && userId == $userId]) > 0

CommentMessageContent renders a message, mentions included. For a label or a hover title, toPlainText flattens one to a string with mentions as @Name when you pass the user lookup.

Which document, and which release

Reads and writes take a DocumentHandle: a document ID and type, plus an optional perspective. Project and dataset come from the surrounding SanityApp or ResourceProvider, so the handle is usually as short as it looks:

const handle = {documentId: 'post-123', documentType: 'post'}

A release perspective in the handle scopes comments to that release, so the same field can carry different threads in a draft and in a release. Every other perspective pools draft and published, which is why a comment written against the published document and one written against the draft turn up in the same list.

Pooling is a default rather than a rule. The read takes a scope option (default 'perspective'), documented in full by the SDK: 'exact' matches only the ID you passed, and 'all' returns every comment on the document.

Status

A comment is 'open' or 'resolved'. Resolving is a property of the thread rather than of each comment, so only the parent carries an action for it. CommentThread passes onStatusChange to the parent comment alone, and the default action list leaves the entry out for a reply.

Resolved is not deleted. A resolved thread still exists and still reads, and is filtered out of the default view. That is the case behind onActiveCommentNotFound: a link to a resolved comment points at something an open-only panel is not showing.

System comments

Some comments are authored by Sanity rather than by a person, and carry an author ID of <system>. That ID always resolves to "Sanity", with the Sanity logo as its avatar, whatever your user lookup returns, so your directory never has to know about it.

Where comments live

Comments are stored per organization and reached through the collaboration API. Sanity Studio comments before Comments API v2 were stored per dataset. Nothing in your app's own dataset changes when a comment is written.

Where to go next

Was this page helpful?

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