Collaboration (Beta)

Users and identity

Why comments store user IDs, how names and avatars are looked up at render time, and where the user directory comes from.

Comments store user IDs, not names. Every name and avatar on a comments surface is looked up at render time through one function on the provider. That makes a user directory the one thing no comments surface can do without. This page explains where that directory comes from, and why the lookup has the shape it does.

Where the current user and user lookup come from

SDKCommentsProvider is the one to reach for on a Sanity app. It is CommentsProvider with the two identity answers filled in from the App SDK: the current user from the auth store, and everybody else from the user store, an ID at a time as the comments ask for them. So on a Sanity project this is the whole of it:

<Suspense fallback={<p>Loading…</p>}>
  <SDKCommentsProvider>
    <Panel />
  </SDKCommentsProvider>
</Suspense>

It suspends until the current user resolves, the way the SDK's own reads do, so the wait is a Suspense boundary above it rather than a prop. A viewer who is logged out, or whose sign-in failed, throws to the nearest error boundary instead: there is no anonymous mode to fall back to, so waiting would never end. Keep it inside your app's auth boundary, or reach for CommentsProvider and answer for the viewer yourself.

CommentsProvider is the same provider with those two answers supplied by you:

<CommentsProvider currentUser={me} getUser={(id) => usersById.get(id) ?? null}>
  <Panel />
</CommentsProvider>

Reach for CommentsProvider when:

  • The users are already in hand. A panel with a mention picker has called useUsers for its options, so the directory is in memory already. The SDK-backed lookup does not know that, and subscribes per author ID anyway.
  • You know the viewer without asking. From the server or your own session, so the panel renders on the first paint rather than flashing a fallback while identity resolves.
  • You are rendering in a test or a harness. A fixture user and a Map are the whole of a lookup, and no part of the panel needs the SDK to be there.
  • The names are not the Sanity ones. Localized display names, your own avatar CDN, or a shared account that should read "Support".
  • The viewer is not a Sanity user at all. This is a public or customer-facing surface, and the only case where the SDK-wired variant has nothing to resolve.

Everything under either one is identical. The panel does not know which of the two is above it.

currentUser is required, so render a fallback until you have it rather than passing a placeholder. A placeholder ID gets the "you" labels and the default edit and delete capabilities wrong.

The getUser function is synchronous and has three answers

The components need a name while they render, so getUser answers immediately:

type GetUser = (userId: string) => CommentUser | null | undefined

Answer

Means

Renders

a user

resolved

the avatar and the display name

undefined

still being fetched

a skeleton

null

there is no such one

the empty avatar, and "Unknown"

The two unresolved answers are worth keeping apart. A lookup that returns undefined for a deleted author leaves that comment showing a skeleton forever, since nothing is coming. Adding ?? null is usually the whole fix.

When a pending user arrives, return a new function identity. That is what re-renders the skeletons into names.

This is the part that cannot be designed away. A message embeds mention IDs inside Portable Text and a reaction carries a bare user ID, so the data cannot be enriched up front without rewriting it.

useCommentUser is the same lookup, in the shape the primitives that show a user take:

const {user, loading} = useCommentUser(comment.authorId)

<UserAvatar user={user} loading={loading} />

A user is loading only while the lookup answers undefined. Once it answers null, this settles with no user and loading: false, which is what renders "Unknown" rather than a skeleton that never resolves.

The system user

One ID is answered for you. <system>, the author of comments Sanity writes itself, always resolves to "Sanity" with the Sanity logo, whatever your lookup returns. Your directory never has to know about it.

What a mention carries

A mention is an inline object inside a message's Portable Text, and it carries a userId and nothing else:

{_type: 'mention', _key: 'm1', userId: 'user-def'}

So rendering a name means looking the user up, through the same three-answer lookup as an author. CommentMessageContent renders a mention as a MentionChip: the display name, prefixed with @, and suffixed with "(you)" when it is the current user. Unlike an avatar or a name, the chip has no loading state: until the lookup returns a user, it shows the raw user ID.

Which users may be mentioned is a permissions question rather than an identity one, and it is answered by the options you pass the provider. See Enable mentions.

Reactions carry user IDs too

Each reaction carries a bare user ID, so the pill tooltips go through the same lookup to name who reacted. When the lookup has no display name for a reactor, the tooltip shows the raw user ID instead, as a mention chip does. The picker offers six shortnames, the set the API accepts, and the provider's reactionOptions prop can narrow or reorder them.

Rendering is not narrowed by that set. A reaction whose shortname the package does not know renders as its own text rather than disappearing.

Where to go next

Was this page helpful?

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