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

# 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](https://www.sanity.io/docs/collaboration/get-started), so that clicking either side selects the thread on the other.

Prerequisites:

- A working comments panel. See [Get started](https://www.sanity.io/docs/collaboration/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](https://www.sanity.io/docs/collaboration/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](https://www.sanity.io/docs/collaboration/get-started):

**npm**

```shell
npm install @portabletext/editor
```

**pnpm**

```shell
pnpm add @portabletext/editor
```

**yarn**

```shell
yarn add @portabletext/editor
```

**bun**

```shell
bun add @portabletext/editor
```

Inline comments come from the same entry point as `SDKCommentsPanel`:

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

Nothing about the panel changes when you add them.

The SDK version from [Get started](https://www.sanity.io/docs/collaboration/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`:

**BodyField.tsx**

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

**BodyField.tsx**

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

**Comments.tsx**

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

**App.tsx**

```tsx
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: 'YOUR_PROJECT_ID',
          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:

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

See [Link to a comment](https://www.sanity.io/docs/collaboration/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:

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

## Troubleshooting

```text
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](https://www.sanity.io/docs/collaboration/get-started).

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

```text
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

- Established variations on this wiring: [Customize inline comments](https://www.sanity.io/docs/collaboration/inline-comment-patterns).
- What is going on underneath: [Inline comments](https://www.sanity.io/docs/collaboration/inline-comments).
- Every signature: [Portable Text](https://collaboration-platform.sanity.dev/?path=/docs/portable-text--docs).



## Related changelog entries

Entries are listed newest first; each link points to the full entry as markdown. Follow one when you need to know what changed, when, or why — for example, to summarize recent updates, explain behavior that differs from older documentation, or check whether a fix has shipped.

- [@sanity/collaboration v0.1.0: First beta release of the comment components](https://www.sanity.io/docs/changelog/88be4643-749f-4954-8493-689b43490d18.md) — October 26, 2026