Collaboration (Beta)

Link to a comment

Make a shared link open the comments panel on the comment it names, with wiring for each common router.

A pasted link should open the panel on the comment it names, scroll to it, expand it out from behind collapsed replies, and select its thread. This guide gives the wiring for each router, so you can copy the one you use and skip the rest.

Prerequisites:

  • A working comments panel. See Get started with comments.
  • Whichever router your app already uses. Nothing here asks you to change it.

Start with what you already have

Every comment under a CommentsProvider is already selectable and revealable, with no props at all. The provider keeps the address in React state, which is enough for selection, for a modal that opens on a comment and closes, and for jumping from anywhere in the subtree. This button takes the threads you read with useCommentThreads and jumps to the oldest one:

import type {CommentThreadData} from '@sanity/collaboration'
import {useActiveComment} from '@sanity/collaboration/ui'

export function JumpToFirst({threads}: {threads: CommentThreadData[]}) {
  const {setActiveComment} = useActiveComment()
  // The oldest thread, whatever order the list is in.
  const thread = threads.reduce<CommentThreadData | undefined>(
    (oldest, candidate) =>
      !oldest ||
      candidate.parentComment.createdAt < oldest.parentComment.createdAt
        ? candidate
        : oldest,
    undefined,
  )

  return (
    <button
      disabled={!thread}
      onClick={() => thread && setActiveComment(thread.parentComment.id)}
    >
      Jump to the first comment
    </button>
  )
}

The components match the address against the threads you passed in, hold it pending while data loads, expand replies collapsed behind Show N more, scroll the comment into view, move focus to it, and select the thread that holds it. The same holds once your router owns the address, as in the next section. Selecting a card writes the address back, so no CommentThread needs selected or onSelect.

Hand the address to your router

For a link someone can paste, the address has to live in the URL, which means your router owns it. Rendering the activeComment prop is what hands it over:

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

Each router recipe on this page shows those two props alone. They go on whichever provider your panel is already under, CommentsProvider or SDKCommentsProvider.

Whenever you pass activeComment, pass onActiveCommentChange too. Rendering activeComment hands the address to your router, and a router that never hears about a click has no way to write it back: a link would open the panel on the comment it names, but no click in it could ever move to another.

A list whose selection lives in the URL, with React Router, is in the link-to-a-comment example.

activeComment is a CommentAddress, which is {type: 'comment', commentId} plus an optional reveal, or undefined for none. Rebuilding it on every render is fine: a reveal happens once per address, keyed on the comment ID rather than on object identity.

Nothing in the package reads window.location. Your router is the only thing that can observe its own location, which is why the address is passed in rather than detected. Comment navigation and deep links explains that split.

Two helpers cover the common case where the ID sits in a query parameter:

import {parseCommentAddress, withCommentAddress} from '@sanity/collaboration'

const address = parseCommentAddress(params)
const next = withCommentAddress(params, address)

Both take URLSearchParams, which covers a query string, a hash fragment (new URLSearchParams(location.hash.slice(1))), and Next.js ReadonlyURLSearchParams. withCommentAddress returns a copy, so it can go straight to a router's setter, and an undefined address removes the parameter rather than emptying it.

Link with React Router

When to use this: a single-page app on React Router. The examples in these guides use this setup.

Install React Router if your app does not have it yet:

npm install react-router

useSearchParams gives you both directions:

import {parseCommentAddress, withCommentAddress} from '@sanity/collaboration'
import {SDKCommentsProvider} from '@sanity/collaboration/sdk'
import {useSearchParams} from 'react-router'

export function Comments() {
  const [searchParams, setSearchParams] = useSearchParams()

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

useSearchParams needs a router above it. Render BrowserRouter inside AuthBoundary, around Comments:

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

import {Comments} from './Comments'

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

Link with the Next.js App Router

When to use this: the App Router, where the search params hook is read-only and navigation goes through the router object.

useSearchParams returns read-only params, which is exactly what parseCommentAddress takes. Writing goes back through router.replace.

'use client'

import {parseCommentAddress, withCommentAddress} from '@sanity/collaboration'
import {SDKCommentsProvider} from '@sanity/collaboration/sdk'
import {usePathname, useRouter, useSearchParams} from 'next/navigation'

export function Comments() {
  const pathname = usePathname()
  const params = useSearchParams()
  const router = useRouter()

  return (
    <SDKCommentsProvider
      activeComment={parseCommentAddress(params)}
      onActiveCommentChange={(next) => {
        const search = withCommentAddress(new URLSearchParams(params), next)
        router.replace(search.size ? `${pathname}?${search}` : pathname, {
          scroll: false,
        })
      }}
    >
      {/* … */}
    </SDKCommentsProvider>
  )
}

Two details in there are the whole reason this pattern differs from React Router's.

{scroll: false}. The App Router otherwise jumps the window to the top on every selection, fighting the scroll the package has already performed. This is the single most common thing to get wrong here.

search.size ? … : pathname. Clearing the address means removing the parameter. withCommentAddress deletes it for you, and this line keeps a trailing ? off the URL once nothing is left in the query string.

There is no window during a server render, so parseCommentAddress taking the params your router hands you is also what makes the address available on the first client render without an effect. The rest of what Next.js changes is in Use comments in Next.js.

Link with TanStack Router

When to use this: TanStack Router, where search is a typed object rather than URLSearchParams. This pattern skips both helpers.

import {SDKCommentsProvider} from '@sanity/collaboration/sdk'
import {useNavigate, useSearch} from '@tanstack/react-router'

const {comment} = useSearch({strict: false})
const navigate = useNavigate()

<SDKCommentsProvider
  activeComment={comment ? {type: 'comment', commentId: comment} : undefined}
  onActiveCommentChange={(next) =>
    navigate({
      to: '.',
      search: (prev) => ({...prev, comment: next?.commentId}),
      replace: true,
    })
  }
>

Read the comment ID from the path

When to use this: the comment ID belongs to a route rather than to a query string, which is common when a comment has a page of its own.

Build the address from whatever your route gives you, and write it back by navigating to the matching route. The following example uses React Router with comments routed at /comments/:commentId. Adapt the hooks and the path to your own router and route format:

// Example only: React Router, with comments routed at /comments/:commentId.
// Adapt the hooks and the path to your own router and route format.
import {useNavigate, useParams} from 'react-router'

const {commentId} = useParams()
const navigate = useNavigate()

activeComment={commentId ? {type: 'comment', commentId} : undefined}
onActiveCommentChange={(next) =>
  navigate(next ? `/comments/${next.commentId}` : '/comments', {replace: true})
}

Link without a router

When to use this: a surface with no routing, but where you still want the link to be shareable.

For selection alone, pass nothing: with no activeComment prop the provider keeps the address in React state, and defaultActiveComment is there for opening on a particular comment.

To make links shareable without a router, mirror the URL into state. history.replaceState fires no event, so the URL on its own cannot drive a re-render, which is the same reason this package does not read the URL for you.

const [address, setAddress] = useState(() =>
  parseCommentAddress(new URLSearchParams(window.location.search)),
)

useEffect(() => {
  const onPopState = () =>
    setAddress(parseCommentAddress(new URLSearchParams(window.location.search)))

  window.addEventListener('popstate', onPopState)
  return () => window.removeEventListener('popstate', onPopState)
}, [])

const handleChange = useCallback((next?: CommentAddress) => {
  setAddress(next)

  const search = withCommentAddress(
    new URLSearchParams(window.location.search),
    next,
  )

  history.replaceState(null, '', search.size ? `?${search}` : location.pathname)
}, [])

// <CommentsProvider activeComment={address} onActiveCommentChange={handleChange}>

The state is the source of truth for rendering. The URL is written so the link is shareable and so popstate has something to read back.

Handle a link to a comment the panel is hiding

The address a link carries is often for a comment the panel is not currently showing: it was resolved and the filter is on open, or it lives on another tab.

CommentsPanel and SDKCommentsPanel handle the common case themselves: given threads of both statuses, they move the filter to whichever status holds the comment the link names. Nothing to wire.

A CommentList of your own knows nothing about your filters, so it hands the address back through onActiveCommentNotFound on the provider. Widen what you show and the pending address lands on the next render, expanding collapsed replies on the way. It works in both modes. Keep status in the component that renders the provider, and pass it down past the Suspense boundary to the component that reads the threads:

import {
  type CommentStatus,
  parseCommentAddress,
  withCommentAddress,
} from '@sanity/collaboration'
import {SDKCommentsProvider} from '@sanity/collaboration/sdk'
import {Suspense, useState} from 'react'
import {useSearchParams} from 'react-router'

import {Threads} from './Threads'

export function Comments() {
  const [searchParams, setSearchParams] = useSearchParams()
  const [status, setStatus] = useState<CommentStatus | undefined>('open')

  return (
    <Suspense fallback={<p>Resolving your identity…</p>}>
      <SDKCommentsProvider
        activeComment={parseCommentAddress(searchParams)}
        onActiveCommentChange={(next) =>
          setSearchParams((params) => withCommentAddress(params, next), {
            replace: true,
          })
        }
        // `undefined` reads both statuses.
        onActiveCommentNotFound={() => setStatus(undefined)}
      >
        <Suspense fallback={<p>Loading comments…</p>}>
          <Threads status={status} />
        </Suspense>
      </SDKCommentsProvider>
    </Suspense>
  )
}

Threads passes status to useCommentThreads. Omitting it, which is what undefined does, reads open and resolved threads together:

import type {CommentStatus} from '@sanity/collaboration'
import {useSDKThreadCallbacks} from '@sanity/collaboration/sdk'
import {CommentList, CommentThread} from '@sanity/collaboration/ui'
import {useCommentThreads} from '@sanity/sdk-react/collaboration'

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

export function Threads({status}: {status: CommentStatus | undefined}) {
  const {threads} = useCommentThreads({...handle, status})
  const threadCallbacks = useSDKThreadCallbacks(handle)

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

Skip onActiveCommentNotFound and a shared link to a resolved comment silently does nothing.

CommentList is what reports that the data settled, because a list is where "this is all the data there is" is known. Two cases follow from that:

  • A surface with no list on screen never reports it. A lone CommentThread in a modal claims and reveals its comment, but a stale ID is your own lookup coming back empty, so render your own "that comment has been deleted" state.
  • A thread layout of your own reports it with useActiveCommentSettled, passing whatever settling is a function of. Without that call the address stays pending and onActiveCommentNotFound never fires.

Add a Copy link action

Nothing in the package knows what your URLs look like, so onCopyLink hands you the comment and you build the rest. Without it, the action does not appear in the menu at all.

<CommentThread
  thread={thread}
  onCopyLink={(comment) => {
    const url = new URL(window.location.href)
    url.search = String(
      withCommentAddress(url.searchParams, {
        type: 'comment',
        commentId: comment.id,
      }),
    )
    void navigator.clipboard.writeText(url.toString())
  }}
/>

The address names the comment that was clicked, a reply included, so a click and that reply's Copy link action agree on the same ID.

Next steps

Was this page helpful?

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