obsidian/wiki/payloadcms/custom-components.md
2026-05-15 15:13:56 +01:00

306 lines
9 KiB
Markdown

---
tags: [payloadcms, tech-patterns]
topic: payloadcms
sources: [raw/custom-components__overview.md, raw/custom-components__root-components.md, raw/custom-components__dashboard.md, raw/custom-components__edit-view.md, raw/custom-components__list-view.md, raw/custom-components__document-views.md, raw/custom-components__custom-views.md, raw/custom-components__custom-providers.md]
created: 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.js` is auto-generated at startup — never edit it directly
## Config / Setup
### Component path syntax
```ts
// 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:
```ts
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)
```ts
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`:
```tsx
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 |
```tsx
// 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" />
}
```
```tsx
// 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:
```ts
admin: {
components: {
views: { list: { Component: '/path/to/MyListView' } },
},
}
```
```tsx
// 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 |
```ts
// 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)
```ts
// 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:
```tsx
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
```ts
admin: {
components: {
providers: ['/components/MyProvider'],
},
}
```
```tsx
'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/Button` for tree-shaking.
- **Non-serializable props:** `full config`, functions, class instances cannot cross the server/client boundary — use `clientProps` for serializable data only.
- `useField` on Lexical fields: `setValue` saves but doesn't re-render editor UI — use `dispatchFields` with `initialValue` too.
- `importMap.js` is regenerated every startup and on HMR — never edit manually.
- Custom views are public; add `if (!initPageResult.req.user) return <Redirect />` yourself.
- `Upload` custom component must use Payload's `<Upload>` from `@payloadcms/ui`, not a bare `<input type="file">`.
## Related
- [[wiki/payloadcms/admin|Admin Panel]]
- [[wiki/payloadcms/admin-panel-overview|Admin Panel Overview]]
- [[wiki/payloadcms/configuration|Configuration]]