A collaborative commenting plugin for Payload CMS v3. Adds a full-featured comments system to the Payload admin panel — supporting document-level and field-level comments on both collections and globals, @mentions with email notifications, comment resolution, multi-tenancy, and locale-aware filtering.
- AI Integration Prompt
- UI Screenshots
- Features
- Prerequisites
- Installation
- Setup
- Configuration
- Translations
- Environment Variables
- Architecture Overview
- Exports Reference
- Available Scripts
- Contributing
- License
Copy and paste this prompt into your AI assistant (Cursor, Claude, etc.) to integrate the plugin into an existing Payload v3 project.
I want to add a collaborative commenting system to my Payload CMS v3 project using @focus-reactive/payload-plugin-comments.
## How it works
The plugin injects into every collection and global:
- A field-level comment badge on every field label — shows comment count, opens a popup to post
- A document-level comments drawer (sidebar) scoped to the current document
- A global comments panel (header button) listing all comments across every document and global
Comments are stored in an auto-generated `comments` collection (hidden from the sidebar). Each comment records:
- documentId / collectionSlug / globalSlug — what it belongs to
- fieldPath — dot-notation field path (null = document-level)
- locale — for field-level comments (null = shown in all locales)
- text — may contain @(userId) mention tokens
- author, mentions, isResolved, resolvedBy, resolvedAt
- tenant (optional, when multi-tenancy is enabled)
@mentions use autocomplete from the users collection. Mentioned users receive email notifications via Resend.
Comments are automatically deleted when their parent document is deleted.
## Installation
pnpm add @focus-reactive/payload-plugin-comments
## Step 1 — Register the plugin in payload.config.ts
import { buildConfig } from 'payload'
import { commentsPlugin } from '@focus-reactive/payload-plugin-comments'
export default buildConfig({
plugins: [
commentsPlugin({
// Optional: specify which field to use as document title in the UI
collections: [
{ slug: 'pages', titleField: 'title' },
{ slug: 'products', titleField: 'name' },
],
// Optional: customize the display name field on users
usernameFieldPath: 'name', // default
}),
],
})
## Step 2 — Import the stylesheet
In your global CSS or admin layout:
@import "@focus-reactive/payload-plugin-comments/styles.css";
Or in a TypeScript/JS file:
import "@focus-reactive/payload-plugin-comments/styles.css";
## Step 3 — Regenerate the import map
Run this after adding the plugin so Payload registers the plugin's admin components:
npx payload generate:importmap
(Or the equivalent for your package manager: pnpm payload generate:importmap / bunx payload generate:importmap)
If you skip this step the comment badges, drawer, and header button will not appear in the admin UI.
## Step 4 — Create and run a migration (SQL adapters only)
The plugin adds a `comments` collection to your database. If you use PostgreSQL or SQLite, create and apply a migration:
npx payload migrate:create create_comments
npx payload migrate
Skip this step if you use the MongoDB adapter.
## Optional: Multi-tenancy
commentsPlugin({
tenant: {
enabled: true,
collectionSlug: 'tenants', // default
documentTenantField: 'tenant', // default
},
})
## Optional: Collection overrides
commentsPlugin({
overrides: {
access: { /* custom access control */ },
fields: (defaultFields) => [...defaultFields],
hooks: {
afterChange: [async ({ doc }) => { /* ... */ }],
},
},
})
## Environment variables (for email notifications)
RESEND_API_KEY=re_xxxxxxxxxxxxxxxx
RESEND_FROM_EMAIL=comments@yourdomain.com
## Important notes
- All collections and globals automatically get field-level comments — `collections` config only sets UI metadata (titleField).
- Comments on globals use globalSlug instead of collectionSlug/documentId.
- Field-level comments are locale-scoped; document-level comments show in all locales.
- The `comments` collection is hidden from the admin sidebar by default.
- Auto-cleanup: when a document is deleted, all its comments are deleted too.
- usernameFieldPath supports dot-notation (e.g. "profile.displayName").
- If RESEND_FROM_EMAIL is not set, mention emails are silently skipped.
A header button in the Payload admin opens a drawer listing all comments across every document and collection.
When viewing a document, a side panel shows all comments scoped to that document with filter tabs (Open / Resolved / Mentioned me).
Clicking the comment badge on a field label opens a popup where you can write and post a new comment for that specific field.
The comment button embedded in the field label has two visual states: no comments (inactive, appears on hover) and one or more open comments (active, showing the count badge).
| Inactive (no comments) | Active (has comments) |
|---|---|
![]() |
![]() |
- Document-level comments — Leave comments on any document in any collection or global
- Field-level comments — Comment directly on individual fields; the field label shows a badge with the comment count
- Global support — Field-level and document-level comments work on Payload Globals, not just collections
- @mention users — Mention other users in comments using
@nameautocomplete - Email notifications — Mentioned users receive email notifications via Resend
- Resolve comments — Mark comments as resolved/unresolved; filter by open, resolved, or mentioned
- Global comments panel — A header button opens a drawer showing all comments across all documents and globals
- Optimistic UI — Comments appear instantly before the server confirms
- Multi-tenancy — Scope comments to tenants via
@payloadcms/plugin-multi-tenant - Locale-aware — Field-level comments are tied to a locale; document-level comments are shown in all locales
- Auto-cleanup — Comments are automatically deleted when their parent document is deleted
- i18n / Translations — All UI strings are translatable; ship your own locale overrides alongside the built-in English defaults
- Node.js 20 or higher
- A working Payload CMS v3 project with Next.js
- pnpm (recommended)
- A Resend account (required only for mention email notifications)
pnpm add @focus-reactive/payload-plugin-commentsnpm install @focus-reactive/payload-plugin-comments
# or
yarn add @focus-reactive/payload-plugin-commentsPeer dependencies: payload ^3.0.0, @payloadcms/ui ^3.0.0. next ^14 || ^15, react ^18 || ^19, react-dom, and @payloadcms/plugin-multi-tenant are optional.
// payload.config.ts
import { buildConfig } from "payload";
import { commentsPlugin } from "@focus-reactive/payload-plugin-comments";
export default buildConfig({
plugins: [
commentsPlugin({
// Optional: customize which field is used as the document title in the UI
collections: [
{ slug: "pages", titleField: "title" },
{ slug: "products", titleField: "name" },
],
}),
],
// ... rest of your config
});Add the plugin's stylesheet to your global CSS or admin layout:
/* global CSS */
@import "@focus-reactive/payload-plugin-comments/styles.css";Or import it in a layout/page file:
import "@focus-reactive/payload-plugin-comments/styles.css";Payload needs its import map regenerated whenever you add or remove a plugin that registers admin components. Run:
npx payload generate:importmapThis command works regardless of your package manager (npm, pnpm, yarn, bun). Alternatively use the equivalent for your package manager:
pnpm payload generate:importmap
# yarn payload generate:importmap
# bunx payload generate:importmapIf you skip this step the plugin's admin components (comment badges, drawer, header button) will not appear in the Payload admin UI.
The plugin adds a comments collection to your database. If you use a SQL adapter (PostgreSQL, SQLite), create and apply a migration:
npx payload migrate:create create_comments
npx payload migrateSkip this step if you use the MongoDB adapter — Payload creates collections automatically.
The commentsPlugin factory accepts an optional CommentsPluginConfig object:
commentsPlugin(config?: CommentsPluginConfig)| Option | Type | Default | Description |
|---|---|---|---|
collections |
CollectionEntry[] |
— | UI metadata for collections (title field). All collections get comments regardless. |
enabled |
boolean |
true |
Set to false to disable the plugin entirely |
tenant |
TenantPluginConfig |
— | Multi-tenancy settings (see below) |
overrides |
CollectionOverrides |
— | Customize the generated comments collection |
translations |
Translations |
— | Override UI strings per locale (see below) |
usernameFieldPath |
string |
"name" |
Dot-notation path to the display name field on the users collection |
Each entry in collections is an object that provides UI metadata for a specific collection:
interface CollectionEntry {
slug: string;
titleField?: string; // Field used as document title in the UI. Default: "id"
}Note: You do not need to list every collection. All collections (and all globals) automatically support comments. The
collectionsarray only sets display metadata like the title field.
Examples:
commentsPlugin({
collections: [
{ slug: "pages", titleField: "title" },
{ slug: "products", titleField: "name" },
],
});Specifies which field on the users collection is used as the display name in comment UI and @mention autocomplete. Supports dot-notation for nested fields.
commentsPlugin({
usernameFieldPath: "name", // default
// usernameFieldPath: "firstName",
// usernameFieldPath: "profile.displayName",
});Configure multi-tenancy when using @payloadcms/plugin-multi-tenant:
| Option | Type | Default | Description |
|---|---|---|---|
enabled |
boolean |
false |
Enable tenant scoping |
collectionSlug |
string |
"tenants" |
Slug of the tenants collection |
documentTenantField |
string |
"tenant" |
Field on document collections that holds the tenant reference |
Example:
commentsPlugin({
tenant: {
enabled: true,
collectionSlug: "tenants",
documentTenantField: "tenant",
},
});Use overrides to customize the generated comments collection — for example, to extend access control or add custom fields:
commentsPlugin({
overrides: {
access: {
// Override the default "authenticated only" access
},
fields: (defaultFields) => [...defaultFields],
hooks: {
afterChange: [
async ({ doc }) => {
console.log("Comment changed:", doc.id);
},
],
},
},
});Use translations to override any UI string for one or more locales. Each key is a locale code; the value is a partial object of the CommentsTranslations shape — keys you omit fall back to the built-in English defaults.
commentsPlugin({
translations: {
fr: {
label: "Commentaires",
add: "Ajouter un commentaire",
writeComment: "Écrire un commentaire",
comment: "Commenter",
cancel: "Annuler",
resolve: "Résoudre",
reopen: "Rouvrir",
delete: "Supprimer",
filterOpen: "Ouverts",
filterResolved: "Résolus",
filterMentioned: "Me mentionnent",
},
},
});All translatable keys (with their English defaults):
| Key | Default (English) |
|---|---|
label |
"Comments" |
openComments_one |
"{{count}} open comment" |
openComments_other |
"{{count}} open comments" |
add |
"Add comment" |
writeComment |
"Write a comment" |
comment |
"Comment" |
cancel |
"Cancel" |
posting |
"Posting…" |
resolve |
"Resolve" |
reopen |
"Reopen" |
delete |
"Delete" |
general |
"General" |
close |
"Close" |
syncingComments |
"Syncing comments" |
openCommentsAria |
"Open comments" |
failedToPost |
"Failed to post comment" |
failedToUpdate |
"Failed to update comment" |
failedToDelete |
"Failed to delete comment" |
failedToAdd |
"Failed to add comment" |
unknownAuthor |
"Unknown" |
deletedUser |
"Deleted user" |
noOpenComments |
"No open comments" |
noResolvedComments |
"No resolved comments" |
noMentionedComments |
"No comments mentioning you" |
filterOpen |
"Open" |
filterResolved |
"Resolved" |
filterMentioned |
"Mentioned me" |
noMentionMatches |
"No matches" |
The CommentsTranslations type is exported from the package so you can type your translation objects:
import type { CommentsTranslations } from "@focus-reactive/payload-plugin-comments";
const myTranslations: Record<string, Partial<CommentsTranslations>> = {
fr: { label: "Commentaires" },
de: { label: "Kommentare" },
};
commentsPlugin({ translations: myTranslations });| Variable | Description | Example |
|---|---|---|
RESEND_API_KEY |
Your Resend API key | re_xxxxxxxxxxxxxxxx |
RESEND_FROM_EMAIL |
Sender email address for mention notifications | comments@yourdomain.com |
If RESEND_FROM_EMAIL is not set, mention email notifications are silently skipped and an error is logged to the console.
.env.local example:
RESEND_API_KEY=re_xxxxxxxxxxxxxxxx
RESEND_FROM_EMAIL=comments@yourdomain.comPlugin initialization (plugin.ts):
- The plugin receives a
CommentsPluginConfigand returns a standard PayloadPluginfunction. - It creates a
commentscollection (hidden from the admin sidebar by default). - It patches every collection to inject
FieldCommentLabelinto each field's admin label and registers anafterDeletehook that cascade-deletes comments when a document is removed. - It patches every global to inject
FieldCommentLabelinto each field's admin label. - It registers an admin provider (
CommentsProviderWrapper) and one admin action (CommentsHeaderButton). - It registers REST endpoints under
/api/comments-plugin/*(list, create, delete, resolve, mark-read, unread-mentions-count, mentionable-users, document-titles, field-labels). Each one requires an authenticated user and uses the Payload instance from the request, so it works on a freshly started serverless instance.
Data loading (src/api):
- The admin UI fetches comments, document titles, mentionable users, field labels, and unread mention counts with TanStack Query from the plugin's endpoints.
- Mutations (add, delete, resolve, mark as read) call the same endpoints and update the query cache optimistically.
State management (CommentsProvider):
- Holds
allCommentsin React state with optimistic updates viauseOptimistic. visibleCommentsis derived: filtered to the current document/collection/global/locale based on the Next.jspathname.- Exposes
addComment,removeComment,resolveComment, andsyncCommentsmutations.
Field-level comments (FieldCommentLabel):
- The plugin overrides the
Labelcomponent for every named field in every configured collection and global. - The label reads comments from context and filters by field path, showing a badge with the count.
- Clicking the badge opens the comments drawer pre-scrolled to that field's comment group.
Comments collection schema:
| Field | Type | Description |
|---|---|---|
documentId |
number | ID of the document being commented on (null for globals) |
collectionSlug |
text | Slug of the collection (null for global comments) |
globalSlug |
text | Slug of the Payload global (null for collection document comments) |
fieldPath |
text | Dot-notation path of the field (null = document-level) |
locale |
text | Locale of the comment (null = shown in all locales) |
text |
textarea | Comment body (may contain @(userId) mention tokens) |
mentions |
array → relationship | Users mentioned in this comment |
author |
relationship → users | Comment author (set automatically) |
isResolved |
checkbox | Whether the comment is resolved |
resolvedBy |
relationship → users | Who resolved it |
resolvedAt |
date | When it was resolved |
tenant |
relationship (optional) | Tenant scope (when multi-tenancy is enabled) |
| Import path | Exports |
|---|---|
@focus-reactive/payload-plugin-comments |
commentsPlugin, CommentsPluginConfig (type), CommentsTranslations (type) |
@focus-reactive/payload-plugin-comments/styles.css |
Plugin stylesheet (Tailwind-compiled CSS) |
Run these from the project root with pnpm:
| Command | Description |
|---|---|
pnpm build |
Build the plugin to dist/ (tsup + Tailwind CSS minification) |
pnpm dev |
Build in watch mode — rebuilds on file changes |
pnpm lint |
Lint + check formatting (Ultracite: oxlint + oxfmt) |
pnpm lint:fix |
Lint + fix, including formatting (Ultracite) |
pnpm format |
Format files (Ultracite: oxfmt) |
pnpm format:check |
Check formatting without writing |
- Fork the repository and create a feature branch.
- Install dependencies:
pnpm install - Start the build watcher:
pnpm dev - Make your changes in
src/. - Run
pnpm lintandpnpm format:checkbefore submitting. - Open a pull request against
main.
MIT © Focus Reactive




