Plugins
ConversationsAdmin UI

Slots

Add your own components to the drawer, the composer and every message, from the config or through props.

A slot adds to the built-in UI without replacing it. There are two kinds:

  • Config-level slots, set in the instance's slots option as Payload component paths. They apply everywhere the instance is drawn in the admin, including the drawer comments() mounts.
  • Prop slots on the components themselves, for UI you compose in code.

To replace a component outright rather than add to it, see Replacing components.

Config-level slots

The comments drawer with every config slot marked: drawerHeader, messageFooter under each message, messageQuickActions and messageActions in the open menu, composerAbove
payload.config.ts
conversations({
  slug: 'comments',
  // ...
  slots: {
    drawerHeader: '/components/ConversationLinks#ConversationLinks',
    messageActions: ['/components/CopyLink#CopyLink', '/components/Flag#Flag'],
    messageFooter: '/components/SeenBy#SeenBy',
  },
})
SlotWhereGets message
drawerHeaderIn the drawer header, after the title and subtitleno (gets the current channel)
composerAboveAbove the editor, under the channel cue. Not while editing a messageno (gets channel)
composerBelowInside the composer's box, between the text and the bottom row, e.g. picked files. Not while editingno (gets channel)
composerActionsNext to Send, before the composer's actions, e.g. an attach button. Not while editingno (gets channel)
messageQuickActionsA row at the top of each message's "⋯" menu, for one-click actionsyes
messageActionsItems in each message's menu, after Reply in thread, Edit and Deleteyes
messageFooterUnder each message body, deleted placeholders includedyes

Each slot takes one component or an array, rendered in order. The host's components come first, then each extension's in the order of extensions (this is how reactions adds its bar and quick row, and attachments its button, picked files and file list). Paths are registered in admin.dependencies, so payload generate:importmap finds them.

messageFooter renders on a deleted placeholder too, because what hangs off a message (reactions, for one) can outlive its text. Check message.deletedAt when your component should not.

The composer slots render inside the composer, so a client component there can add to the send with useComposerAddon (see Adding to the composer).

Client components

A client component renders directly, with ChatSlotProps:

type ChatSlotProps = {
  instance: string
  conversationKey: string
  channel?: string
  message?: ConversationMessage
}
components/CopyLink.tsx
'use client'
import { type ChatSlotProps, useMessageMenu } from '@10x-media/conversations/client'
import { PopupList } from '@payloadcms/ui'

export const CopyLink = ({ conversationKey, message }: ChatSlotProps) => {
  const menu = useMessageMenu()
  if (!message) return null
  return (
    <PopupList.Button
      onClick={() => {
        void navigator.clipboard.writeText(`${location.href}#message-${message.id}`)
        menu?.close()
      }}
    >
      Copy link
    </PopupList.Button>
  )
}

Menu slots render inside Payload's PopupList.ButtonGroup, so PopupList.Button looks native there. useMessageMenu() returns { message, close } inside the menu and null elsewhere; call close() once the action is done.

Slot components sit under the instance's provider, so every hook from @10x-media/conversations/react works in them (useChannels, useExtension, useExtensionApi and the rest).

Server components

A server component is rendered after mount through a server function, with a real request (see How server components reach the admin):

type ChatServerSlotProps = ChatSlotProps & { req: PayloadRequest; locale; permissions }
components/SeenBy.tsx
import type { ChatServerSlotProps } from '@10x-media/conversations/rsc'

export const SeenBy = async ({ message, req }: ChatServerSlotProps) => {
  if (!message || message.deletedAt) return null
  const { totalDocs } = await req.payload.count({ collection: 'receipts', where: { message: { equals: message.id } } })
  return totalDocs > 0 ? <small>Seen by {totalDocs}</small> : null
}

Before rendering, the dispatcher runs the instance's access for the conversation and checks the message's channel against it, so a crafted request shows nothing the user could not read through the endpoints.

One request per slot and message. A server messageFooter in a feed of 30 messages makes 30 server function calls. Results are cached per message and updatedAt for the life of the page, so an edit renders afresh and a re-render does not. Prefer client components for the per-message slots, and fetch what you need in one request there.

Server slots exist only in the admin.

How server components reach the admin

Rendering a server component after mount takes a Next server action, and Payload funnels all of its own through one action declared in your generated app/(payload)/layout.tsx, with no config-level way to add a name to it. So the plugin offers two routes, per instance, with serverComponents.

serverComponents: 'widget', the default

Nothing is asked of you. The plugin registers one internal dashboard widget, Conversations (internal), and renders through Payload's built-in render-widget.

That widget appears in the dashboard's "Add widget" drawer, which readers see only while editing their dashboard. One widget serves every instance, and it is registered only when some instance on this route has a slot or message type component of yours: whether a component is a server one shows only in the import map, so every path you configure counts, while the plugin's own (the reactions and attachments slots) never do. Added to a dashboard by hand, it shows a notice that it is not for display and can be removed.

serverComponents: 'server-function'

No widget is registered for the instance. In exchange you wire the plugin's own function yourself:

app/(payload)/layout.tsx
import config from '@payload-config'
import { conversationsServerFunctions } from '@10x-media/conversations/rsc'
import { handleServerFunctions, RootLayout } from '@payloadcms/next/layouts'
import type { ServerFunctionClient } from 'payload'

import { importMap } from './admin/importMap.js'

const serverFunction: ServerFunctionClient = async (args) => {
  'use server'
  return handleServerFunctions({
    ...args,
    config,
    importMap,
    serverFunctions: conversationsServerFunctions,
  })
}
payload.config.ts
conversations({ slug: 'comments', serverComponents: 'server-function', /* ... */ })

With another plugin that registers functions the same way, spread both: serverFunctions: { ...conversationsServerFunctions, ...settingsOverlayServerFunctions }.

Payload generates that layout file and marks it "DO NOT MODIFY", so this one is opt in. Forget the wiring and the browser console says so in as many words, naming both the fix and the way back.

Choosing

Take the default unless a stray row in the dashboard's widget drawer bothers you more than an edit to a generated file. If no instance has a slot or message type component of yours, neither is used: nothing is registered and nothing is called.

Custom message type components

A custom message type with a Component is drawn in the same way as a slot: a client component renders directly with ChatSlotProps (message and the view's authors set), a server component goes through the server function with ChatServerSlotProps. A message of a type with no component shows "This message cannot be shown here".

With layout: 'bare' the component is the whole row rather than the body under the author's name; see Layout.

useTypeRenderer(instance) returns the function the admin uses, (message, authors?) => ReactNode | null, for your own markup. In a composed view you can also pass renderType to ChatPanel, ChatDrawer, ChatFeed, ChatThread or ChatMessage; it is tried first, and the configured component is the fallback when it returns null or undefined.

Prop slots

When you compose the components yourself, pass React nodes instead:

ComponentProp
ChatDrawerheaderAfter the title, before the drawerHeader slot
ChatDrawer, ChatPanel, ChatFeed, ChatThread, ChatMessagerenderTypeNon-text messages
ChatPanelrenderHeaderDrawn first, with the panel's state
ChatFeedemptyShown for an empty channel
ChatFeeditemsRows of your own ({ at, id, node }), interleaved by timestamp, e.g. status changes kept elsewhere
ChatMessageheaderReplaces the name and time line
ChatMessagefooterUnder the body, before messageFooter
ChatMessageactionsMenu items, before messageActions
ChatComposeraboveUnder the cue, before composerAbove
ChatComposeractionsButtons next to Send, after composerActions
<ChatFeed
  conversation={conversation}
  instance="tickets"
  empty="No replies yet."
  items={statusChanges.map((change) => ({
    at: change.createdAt,
    id: `status-${change.id}`,
    node: <StatusRow change={change} />,
  }))}
/>

ChatMessage's prop slots are what a replacement message uses to wrap the default.

Slots from an extension

An extension contributes slots through its own slots field, appended after the host's. See Writing your own.

On this page