9 KiB
9 KiB
| tags | topic | sources | created | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
payloadcms |
|
2026-05-15 |
PayloadCMS — Custom Components
Overview
- Every part of the admin UI can be replaced or augmented with your own React components
- All custom components are RSC by default; add
'use client'to opt into client mode - Payload auto-strips non-serializable props before passing them to client components
- Components are referenced by file path strings (not imports) to keep the Node config lightweight
- An
importMap.jsis auto-generated at startup — never edit it directly
Config / Setup
Component path syntax
// Named export via hash
Button: '/src/components/Logout#MyComponent'
// Default export
Button: '/src/components/Logout'
// Object form with extras
Button: {
path: '/src/components/Logout',
exportName: 'MyComponent',
clientProps: { label: 'Sign out' },
serverProps: { someServerData: true },
}
Set admin.importMap.baseDir to shorten paths:
admin: {
importMap: { baseDir: path.resolve(dirname, 'src') },
components: {
logout: { Button: '/components/Logout#MyLogout' },
},
}
Default props every component receives
| Prop | Type | Notes |
|---|---|---|
payload |
Payload |
Full local API (server only) |
i18n |
I18nClient |
Translation object |
Client components also get clientConfig via useConfig().
Key Slots — Root Components (admin.components)
| Slot | Type | Description |
|---|---|---|
graphics.Logo |
component | Full logo on login page |
graphics.Icon |
component | Small icon in nav |
Nav |
component | Replace entire sidebar/mobile nav |
header |
[] |
Inject above Payload header (banners, alerts) |
actions |
[] |
Buttons inside the header bar |
logout.Button |
component | Custom logout button |
beforeDashboard |
[] |
Before default dashboard content |
afterDashboard |
[] |
After default dashboard content |
beforeNavLinks |
[] |
Before nav link list |
afterNavLinks |
[] |
After nav link list |
beforeLogin |
[] |
Before login form |
afterLogin |
[] |
After login form |
settingsMenu |
[] |
Gear-icon popup items above logout |
providers |
[] |
React context providers wrapping entire panel |
views |
object | Replace/add full-page views |
Dashboard Widgets (experimental)
admin: {
dashboard: {
widgets: [{
slug: 'user-stats',
Component: './components/UserStats.tsx#default',
fields: [{ name: 'title', type: 'text' }],
minWidth: 'small',
maxWidth: 'large',
}],
defaultLayout: ({ req }) => [
{ widgetSlug: 'collections', width: 'full' },
{ widgetSlug: 'user-stats', data: { title: 'Users' }, width: 'medium' },
],
},
}
Widget component receives WidgetServerProps:
import type { WidgetServerProps } from 'payload'
export default async function UserStatsWidget({ req, widgetData }: WidgetServerProps) {
const count = await req.payload.count({ collection: 'users' })
return (
<div className="card">
<h3>{widgetData?.title ?? 'Users'}</h3>
<p style={{ fontSize: 32, fontWeight: 'bold' }}>{count.totalDocs}</p>
</div>
)
}
WidgetWidth values: 'x-small' | 'small' | 'medium' | 'large' | 'x-large' | 'full'
Edit View Slots (admin.components.edit on Collection/Global)
| Slot | Notes |
|---|---|
SaveButton |
Props: SaveButtonServerProps / SaveButtonClientProps |
SaveDraftButton |
Requires versions.drafts: true |
PublishButton |
Requires versions.drafts: true |
UnpublishButton |
Requires versions.drafts: true |
PreviewButton |
Requires admin.preview on collection |
Status |
Draft/published indicator |
beforeDocumentControls |
[] — before Save/Publish buttons |
editMenuItems |
[] — items in 3-dot menu dropdown |
Upload |
Collection-only; must integrate with Payload form system |
Description |
Shared with List View |
// Custom save button — server component
import { SaveButton } from '@payloadcms/ui'
import type { SaveButtonServerProps } from 'payload'
export function MySaveButton(props: SaveButtonServerProps) {
return <SaveButton label="Save Draft" />
}
// editMenuItems — client component using PopupList
'use client'
import { PopupList } from '@payloadcms/ui'
import type { EditMenuItemsClientProps } from 'payload'
export const EditMenuItems = (props: EditMenuItemsClientProps) => (
<PopupList.ButtonGroup>
<PopupList.Button onClick={() => console.log(props.id)}>Custom Action</PopupList.Button>
</PopupList.ButtonGroup>
)
List View Slots (admin.components on Collection)
| Slot | Notes |
|---|---|
beforeList |
[] — before entire list |
afterList |
[] — after entire list |
beforeListTable |
[] — before the data table |
afterListTable |
[] — after the data table |
listMenuItems |
[] — items in list controls menu |
Description |
Shared with Edit View |
Replace entire List View:
admin: {
components: {
views: { list: { Component: '/path/to/MyListView' } },
},
}
// Keep built-in table, wrap with custom header
'use client'
import { DefaultListView } from '@payloadcms/ui'
import type { ListViewClientProps } from 'payload'
export function MyListView(props: ListViewClientProps) {
return (
<>
<h1>My Docs</h1>
<DefaultListView {...props} />
</>
)
}
Document Views (views.edit[key] on Collection/Global)
| Key | Description |
|---|---|
root |
Takes over entire document route — no tabs/controls rendered |
default |
Primary edit view (Edit tab) |
versions |
Version history tab |
version |
Single version tab |
api |
REST JSON tab |
livePreview |
Live preview tab |
[custom key] |
Add new tab-based view |
// Add a custom tab to a collection
admin: {
components: {
views: {
edit: {
analytics: {
Component: '/components/AnalyticsView',
path: '/analytics',
tab: { label: 'Analytics', order: '50' },
},
},
},
},
}
Tab component props: DocumentTabServerProps / DocumentTabClientProps
Custom Views (full-page)
// Root-level custom view
admin: {
components: {
views: {
myTool: {
Component: '/components/MyTool#MyTool',
path: '/my-tool',
},
},
},
}
Custom views receive AdminViewServerProps: initPageResult, params, searchParams, doc, payload, i18n.
Use DefaultTemplate for built-in layout/nav:
import { DefaultTemplate } from '@payloadcms/next/templates'
import type { AdminViewServerProps } from 'payload'
export function MyView({ initPageResult, params, searchParams }: AdminViewServerProps) {
if (!initPageResult.req.user) return <p>Not authorized</p>
return (
<DefaultTemplate
payload={initPageResult.req.payload}
permissions={initPageResult.permissions}
user={initPageResult.req.user}
i18n={initPageResult.req.i18n}
locale={initPageResult.locale}
params={params}
searchParams={searchParams}
visibleEntities={initPageResult.visibleEntities}
>
<h1>My Custom View</h1>
</DefaultTemplate>
)
}
All custom views are public by default — add auth check manually.
Custom Providers
admin: {
components: {
providers: ['/components/MyProvider'],
},
}
'use client'
import React, { createContext, use } from 'react'
const Ctx = React.createContext<string>('')
export function MyProvider({ children }: { children: React.ReactNode }) {
return <Ctx value="hello">{children}</Ctx>
}
export const useMyCtx = () => use(Ctx)
Providers must be client components ('use client'). Wrap with a server component to pass server data in.
Gotchas
- Import path collisions: In admin panel code always import from
@payloadcms/ui(bare). In frontend code use deep imports like@payloadcms/ui/elements/Buttonfor tree-shaking. - Non-serializable props:
full config, functions, class instances cannot cross the server/client boundary — useclientPropsfor serializable data only. useFieldon Lexical fields:setValuesaves but doesn't re-render editor UI — usedispatchFieldswithinitialValuetoo.importMap.jsis regenerated every startup and on HMR — never edit manually.- Custom views are public; add
if (!initPageResult.req.user) return <Redirect />yourself. Uploadcustom component must use Payload's<Upload>from@payloadcms/ui, not a bare<input type="file">.