Collaboration (Beta)

Comment on Portable Text

Add inline comments to a Portable Text editor: highlights under the commented words, linked to the comments panel.

Inline comments put a highlight under the words a thread is about, inside a Portable Text editor, and let an author start a thread from a selection. This guide wires an editor that draws those highlights beside the panel from Get started, so that clicking either side selects the thread on the other.

Prerequisites:

  • A working comments panel. See Get started.
  • A Portable Text field to comment on, edited with @portabletext/editor ^8.2.4.

The concepts, including what an anchor is and why it survives edits, are in Inline comments. This page is the wiring.

Step 1: Install the editor

The editor is a peer dependency of @sanity/collaboration, so add it beside the package you installed in Get started:

npm install @portabletext/editor

Inline comments come from the same entry point as SDKCommentsPanel:

import {SDKPortableTextComments} from '@sanity/collaboration/sdk'

Nothing about the panel changes when you add them.

The SDK version from Get started covers inline comments too: re-anchoring is one more write on the same /collaboration entry point.

Step 2: Add the plugin to your editor

Render one component beside the editable, inside the same EditorProvider:

import {
  defineSchema,
  EditorProvider,
  PortableTextEditable,
} from '@portabletext/editor'
import {SDKPortableTextComments} from '@sanity/collaboration/sdk'
import type {DocumentHandle} from '@sanity/sdk'
import {useCommentThreads} from '@sanity/sdk-react/collaboration'

const schemaDefinition = defineSchema({
  decorators: [{name: 'strong'}, {name: 'em'}],
})

export function BodyField({handle}: {handle: DocumentHandle}) {
  const {threads} = useCommentThreads({...handle, status: 'open'})

  return (
    <EditorProvider initialConfig={{schemaDefinition}}>
      <PortableTextEditable />
      <SDKPortableTextComments {...handle} path="body" threads={threads} />
    </EditorProvider>
  )
}

path is the field this editor edits, as a document path. threads is the same read the panel makes, so the highlights and the cards are one set of threads; the plugin draws the open ones anchored under that path, creates new ones with createComment, and writes anchors back with updateCommentAnchor as edits move them. useCommentThreads suspends on first load, so BodyField belongs under a boundary, as it does in Get started.

The plugin needs a CommentsProvider above it as well, because the composer it opens reads the current user and the mention options from there. The panel already has one.

The composer is part of the plugin: with text selected, a Comment button appears at the end of the selection and becomes a CommentInput when clicked.

Step 3: Sync the editor's value

The plugin does not write the editor's value to the document. An editor is usually already wired to do that, and the plugin reads the value from the editor either way.

If you have no sync of your own, @portabletext/plugin-sdk-value handles it with the edge cases covered. Here is the smallest hand-rolled version, with useDocument and useEditDocument. This is the whole of BodyField.tsx from Step 2, with the sync added:

import {
  defineSchema,
  EditorProvider,
  PortableTextEditable,
  type PortableTextBlock,
  useEditor,
} from '@portabletext/editor'
import {SDKPortableTextComments} from '@sanity/collaboration/sdk'
import type {DocumentHandle} from '@sanity/sdk'
import {useDocument, useEditDocument} from '@sanity/sdk-react'
import {useCommentThreads} from '@sanity/sdk-react/collaboration'
import {useEffect} from 'react'

const schemaDefinition = defineSchema({
  decorators: [{name: 'strong'}, {name: 'em'}],
})

export function BodyField({handle}: {handle: DocumentHandle}) {
  const {threads} = useCommentThreads({...handle, status: 'open'})
  const {data} = useDocument<PortableTextBlock[] | undefined, string>({
    ...handle,
    path: 'body',
  })
  const editField = useEditDocument<PortableTextBlock[] | undefined>({
    ...handle,
    path: 'body',
  })

  return (
    <EditorProvider initialConfig={{schemaDefinition, initialValue: data}}>
      <ValueSync value={data} onChange={editField} />
      <PortableTextEditable />
      <SDKPortableTextComments {...handle} path="body" threads={threads} />
    </EditorProvider>
  )
}

function ValueSync({
  value,
  onChange,
}: {
  value: PortableTextBlock[] | undefined
  onChange: (value: PortableTextBlock[] | undefined) => Promise<unknown>
}) {
  const editor = useEditor()

  useEffect(() => {
    editor.send({type: 'update value', value})
  }, [editor, value])

  useEffect(() => {
    const subscription = editor.on('mutation', (event) => {
      void onChange(event.value)
    })
    return () => subscription.unsubscribe()
  }, [editor, onChange])

  return null
}

Sending update value with a value the editor already holds is a no-op, which is what lets an edit go out to the document and come back without disturbing the caret.

Step 4: Wire the panel beside it

The panel is the one from Get started, without its compose prop. New threads now start from a selection in the editor, so the panel no longer needs a composer of its own. A thread started inline is a thread: its quoted snapshot holds the words it was about, and every card action works on it.

Put both sides under one provider and they agree without a line of code between them. Clicking a highlight sets the active comment, so the panel scrolls to and selects the thread; selecting a thread in the panel sets the same value, which the highlight reads to draw its active tone.

import {SDKCommentsProvider, SDKCommentsPanel} from '@sanity/collaboration/sdk'
import type {DocumentHandle} from '@sanity/sdk'
import {Suspense} from 'react'

import {BodyField} from './BodyField'

export function Comments({handle}: {handle: DocumentHandle}) {
  return (
    <Suspense fallback={<p>Resolving your identity…</p>}>
      <SDKCommentsProvider>
        <Suspense fallback={<p>Loading comments…</p>}>
          <BodyField handle={handle} />
          <SDKCommentsPanel {...handle} />
        </Suspense>
      </SDKCommentsProvider>
    </Suspense>
  )
}

Both reads key on the handle's fields, so building handle afresh on each render is fine.

Comments now takes the document as a handle, so update the <Comments /> call in App.tsx from Get started to build one and pass it:

import type {DocumentHandle} from '@sanity/sdk'
import {AuthBoundary, SanityApp} from '@sanity/sdk-react'
import {ThemeProvider} from '@sanity/ui'
import {buildTheme} from '@sanity/ui/theme'

import {Comments} from './Comments'

export function App() {
  const handle: DocumentHandle = {documentId: 'post-123', documentType: 'post'}

  return (
    <ThemeProvider theme={buildTheme()}>
      <SanityApp
        config={{
          projectId: '',
          dataset: 'production',
        }}
        fallback={<p>Starting…</p>}
      >
        <AuthBoundary>
          <Comments handle={handle} />
        </AuthBoundary>
      </SanityApp>
    </ThemeProvider>
  )
}

For a link someone can paste, hand the address to your router instead, with parseCommentAddress and withCommentAddress from @sanity/collaboration. The two sides still agree, because they are still under one provider:

<SDKCommentsProvider
  activeComment={parseCommentAddress(searchParams)}
  onActiveCommentChange={(next) =>
    setSearchParams((params) => withCommentAddress(params, next), {
      replace: true,
    })
  }
>

See Link to a comment for the wiring per router.

Step 5: Check that it worked

Select a few words in the editor and click the Comment button that appears.

Four things should happen:

  • The selection is highlighted while you write and follows any edits you make with the composer open.
  • Submitting keeps the highlight under those words and adds the thread to the panel, with the words quoted on the card.
  • Typing before the highlighted words moves the highlight with them.
  • Deleting the highlighted words drops the highlight, and the thread survives as a field-level comment with its quoted snapshot.

Troubleshooting

Failed to resolve import "@sanity/sdk-react/collaboration"

The installed @sanity/sdk-react is older than 3.11.0, the first version with the /collaboration entry point. Install @sanity/sdk-react and @sanity/sdk at ^3.11.0, as in Get started.

useInlineCommentAuthoring must be called inside an <SDKPortableTextComments> or <PortableTextComments>.

A composer is mounted outside the plugin. The authoring state lives on the plugin, so InlineCommentInput, or a composer of your own, has to be one of its children.

Comment components must be rendered inside a <CommentsProvider>.

The plugin's composer is a comment component, so the provider has to be above the editor as well as around the panel.

No highlights, and the panel shows the threads. The comments are anchored under a different path than this editor's path, or they are resolved. A resolved thread's highlight goes when it is resolved.

The highlight is under the wrong words after a reload. The stored anchor was not kept current. With SDKPortableTextComments, check the console for Re-anchoring comment <id> failed, or the errors reaching onRangeChangeError. With PortableTextComments, check that onRangeChange is passed.

Next steps

Was this page helpful?

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