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-routerpnpm add react-routeryarn add react-routerbun add react-routeruseSearchParams 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: '<your-project-id>',
dataset: 'production',
}}
fallback={<p>Starting…</p>}
>
<AuthBoundary>
<BrowserRouter>
<Comments />
</BrowserRouter>
</AuthBoundary>
</SanityApp>
</ThemeProvider>
)
}Use replace, not push, for click-driven selection. Otherwise the back button walks through every thread the user clicked. Keep push for following a pasted link.
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.
onActiveCommentNotFound fires once per address, so become as permissive as you can in one step. Widening by half means the second half never happens.
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
CommentThreadin 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 andonActiveCommentNotFoundnever 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.
In an app running inside the Sanity Dashboard, the app is in an iframe: window.location.href is the iframe's URL, not the Dashboard URL in the address bar, and navigator.clipboard.writeText needs the iframe to allow clipboard-write.
Next steps
- What the active comment is, and who writes the address: Comment navigation and deep links.
- To make a comment layout of your own respond to links: Build a custom comment.
- Clicking a highlight in an editor is the same value going the other way: Comment on Portable Text.
- Deep links with the Sanity Studio router: Use comments in Sanity Studio.
Was this page helpful?
Feedback submitted, thanks for helping us improve the docs. Have you tried these alternatives?