A Payload CMS plugin that adds a Changes button to drafts-enabled documents. Clicking it opens a slide-in drawer that shows a field-by-field diff between the current document state (or the latest draft) and the currently published version — using the same diff UI as the built-in Versions view.
- Auto-injects into every drafts-enabled collection / global (no manual component wiring).
- Visibility is fully driven by document state: the button only shows when there are unpublished changes AND the user has publish permission.
- Toggle inside the drawer to switch between Unsaved (live form values) and Latest draft (saved draft) when both exist.
- Self-contained — the diff renderer is vendored from Payload's Versions view, so the plugin works against any
payload/@payloadcms/nextrelease without waiting for new exports to land upstream. - All UI strings live in
src/labels.ts(en/ar/es/fr/he/zh) and are picked up viauseTranslation().i18n.language.
Two upstream Payload PRs would let the plugin install with a single line and zero layout edits, but they have not been merged yet:
| Feature | Issue | PR |
|---|---|---|
@payloadcms/next/views/diff subpath export |
#16496 | #16498 |
config.admin.serverFunctions registry |
#16497 | #16499 |
Until they ship, this package vendors the diff pipeline from @payloadcms/next/src/views/Version/RenderFieldsToDiff/ (see src/vendor/diff/) and requires a small (payload)/layout.tsx change to register the server function. Once both PRs land, the vendor copy can be deleted and the layout edit removed.
pnpm add @shefing/changes-buttonimport { buildConfig } from 'payload'
import { changesButtonPlugin } from '@shefing/changes-button'
export default buildConfig({
// ...
plugins: [
changesButtonPlugin({
// optional — exclude collections / globals from receiving the button
excludedCollections: ['users'],
excludedGlobals: [],
}),
],
})The plugin needs a server function (shefing/changes-button:render-diff) registered alongside Payload's built-in ones. Until upstream PR #16499 lands, this is done by wrapping the serverFunction you pass to <RootLayout />:
// app/(payload)/layout.tsx
import type { ServerFunctionClient } from 'payload'
import { handleServerFunctions, RootLayout } from '@payloadcms/next/layouts'
import { wrapServerFunctions } from '@shefing/changes-button/server'
import config from '@payload-config'
import { importMap } from './admin/importMap.js'
const baseServerFunction: ServerFunctionClient = async function (args) {
'use server'
return handleServerFunctions({ ...args, config, importMap })
}
const serverFunction = wrapServerFunctions(baseServerFunction)
export default async function Layout({ children }: { children: React.ReactNode }) {
return (
<RootLayout config={config} importMap={importMap} serverFunction={serverFunction}>
{children}
</RootLayout>
)
}wrapServerFunctions intercepts only the shefing/changes-button:render-diff key and forwards every other call to the base handler unchanged.
| Option | Type | Default | Description |
|---|---|---|---|
excludedCollections |
string[] |
[] |
Slugs of collections that should NOT receive the Changes button. |
excludedGlobals |
string[] |
[] |
Slugs of globals that should NOT receive the Changes button. |
disabled |
boolean |
false |
Disable the plugin entirely without removing it from plugins. |
The button is rendered only when all of the following are true for the open document:
- The entity has drafts enabled (
versions.draftsis configured). - The current user has publish permission.
- The document is not in trash.
- There are unpublished changes — either the form is
modifiedorunpublishedVersionCount > 0.
For brand-new entities (no published baseline) the diff renders against an empty baseline so every populated field shows as an addition.
All user-facing strings are declared in src/labels.ts and consumed via the getLabel(key, locale) helper. The active locale is read from useTranslation().i18n.language so the button automatically follows the admin UI language.
Built-in locales: en, ar, es, fr, he, zh. Missing keys/locales fall back to English.
If you don't want to use wrapServerFunctions, register the handler explicitly in your serverFunctions map:
import { renderChangesDiffHandler, SERVER_FUNCTION_KEY } from '@shefing/changes-button/server'
const serverFunction: ServerFunctionClient = async function (args) {
'use server'
return handleServerFunctions({
...args,
config,
importMap,
serverFunctions: { [SERVER_FUNCTION_KEY]: renderChangesDiffHandler },
})
}The vendored diff pipeline lives in src/vendor/diff/ — a snapshot of @payloadcms/next/src/views/Version/RenderFieldsToDiff/ (minus *.spec.ts). When upstream PR #16498 ships in a release:
- Replace the vendor imports in
src/server/renderChangesDiff.tsxwithimport { countChangedFields, RenderDiff } from '@payloadcms/next/views/diff'. - Delete
src/vendor/diff/and the copiedSelectedLocalesContext.tsx. - Bump the
@payloadcms/nextpeerDependencyto the release that exposes the subpath.
When PR #16499 ships, additionally:
- Re-add
config.admin.serverFunctionsself-registration inChangesButtonPlugin.ts(see git history). - Drop the
wrapServerFunctionsstep from this README — the plugin will be a single-line install again.
See the consolidated ROADMAP.md at the repo root and the live RoadMap issues for Changes Button.
- AI-generated change summary at the top of the drawer:
- Pluggable
summarizeadapter (openai,anthropic,custom). The plugin sends the structured diff + collection field metadata; receives a markdown summary + bullet list of risky changes. - Server-side via a new Payload endpoint registered by the plugin, so API keys stay on the server.
- Cached per
(docId, fromVersion, toVersion). - Per-collection toggle via
admin.custom.changesButton.aiSummary.
- Pluggable
- Inline approval workflow — "Request review" button creating a
change-requestrecord (or hooking intoauthorizationroles). - Comment-on-diff — reuse the
commentsplugin's Lexical mark on changed fields. - Filter the diff (only changed / added / removed; by tab/group).
- Copy summary / export diff as Markdown or PDF.
- Once upstream Payload PRs #16498 / #16499 land, drop
src/vendor/diffand the(payload)/layout.tsxedit. - Granular i18n for AI summaries (locale passed to adapter).
MIT — © shefing