--- 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 (

{widgetData?.title ?? 'Users'}

{count.totalDocs}

) } ``` `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 } ``` ```tsx // editMenuItems — client component using PopupList 'use client' import { PopupList } from '@payloadcms/ui' import type { EditMenuItemsClientProps } from 'payload' export const EditMenuItems = (props: EditMenuItemsClientProps) => ( console.log(props.id)}>Custom Action ) ``` ## 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 ( <>

My Docs

) } ``` ## 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

Not authorized

return (

My Custom View

) } ``` **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('') export function MyProvider({ children }: { children: React.ReactNode }) { return {children} } 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 ` yourself. - `Upload` custom component must use Payload's `` from `@payloadcms/ui`, not a bare ``. ## Related - [[wiki/payloadcms/admin|Admin Panel]] - [[wiki/payloadcms/admin-panel-overview|Admin Panel Overview]] - [[wiki/payloadcms/configuration|Configuration]]