Custom Components allow you to fully customize the Admin Panel by swapping in your own React components. You can replace nearly every part of the interface or add entirely new functionality.
There are four main types of Custom Components:
Components are defined using file paths (not direct imports) to keep the config lightweight and Node.js compatible.
import { buildConfig } from 'payload'
export default buildConfig({
admin: {
components: {
logout: {
Button: '/src/components/Logout#MyComponent', // Named export
},
Nav: '/src/components/Nav', // Default export
},
},
})
Component Path Rules:
config.admin.importMap.baseDir)#ExportName or use exportName propertyInstead of a string path, you can pass a config object:
{
logout: {
Button: {
path: '/src/components/Logout',
exportName: 'MyComponent',
clientProps: { customProp: 'value' },
serverProps: { asyncData: someData },
},
},
}
Config Properties:
| Property | Description |
|---|---|
path |
File path to component (named exports via #) |
exportName |
Named export (alternative to # in path) |
clientProps |
Props for Client Components (must be serializable) |
serverProps |
Props for Server Components (can be non-serializable) |
import path from 'path'
import { fileURLToPath } from 'node:url'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
export default buildConfig({
admin: {
importMap: {
baseDir: path.resolve(dirname, 'src'), // Set base directory
},
components: {
Nav: '/components/Nav', // Now relative to src/
},
},
})
All components are React Server Components by default.
Can use Local API directly, perform async operations, and access full Payload instance.
import React from 'react'
import type { Payload } from 'payload'
async function MyServerComponent({ payload }: { payload: Payload }) {
const page = await payload.findByID({
collection: 'pages',
id: '123',
})
return <p>{page.title}</p>
}
export default MyServerComponent
Use the 'use client' directive for interactivity, hooks, state, etc.
'use client'
import React, { useState } from 'react'
export function MyClientComponent() {
const [count, setCount] = useState(0)
return <button onClick={() => setCount(count + 1)}>Clicked {count} times</button>
}
Important: Client Components cannot receive non-serializable props (functions, class instances, etc.). Payload automatically strips these when passing to client components.
All Custom Components receive these props by default:
| Prop | Description | Type |
|---|---|---|
payload |
Payload instance (Local API access) | Payload |
i18n |
Internationalization object | I18n |
locale |
Current locale (if localization enabled) | string |
Server Component Example:
async function MyComponent({ payload, i18n, locale }) {
const data = await payload.find({
collection: 'posts',
locale,
})
return <div>{data.docs.length} posts</div>
}
Client Component Example:
'use client'
import { usePayload, useLocale, useTranslation } from '@payloadcms/ui'
export function MyComponent() {
// Access via hooks in client components
const { getLocal, getByID } = usePayload()
const locale = useLocale()
const { t, i18n } = useTranslation()
return <div>{t('myKey')}</div>
}
Pass additional props using clientProps or serverProps:
{
logout: {
Button: {
path: '/components/Logout',
clientProps: {
buttonText: 'Sign Out',
onLogout: () => console.log('Logged out'),
},
},
},
}
Receive in component:
'use client'
export function Logout({ buttonText, onLogout }) {
return <button onClick={onLogout}>{buttonText}</button>
}
Root Components affect the entire Admin Panel.
| Component | Description | Config Path |
|---|---|---|
Nav |
Entire navigation sidebar | admin.components.Nav |
graphics.Icon |
Small icon (used in nav) | admin.components.graphics.Icon |
graphics.Logo |
Full logo (used on login) | admin.components.graphics.Logo |
logout.Button |
Logout button | admin.components.logout.Button |
actions |
Header actions (array) | admin.components.actions |
header |
Above header (array) | admin.components.header |
beforeDashboard |
Before dashboard content (array) | admin.components.beforeDashboard |
afterDashboard |
After dashboard content (array) | admin.components.afterDashboard |
beforeLogin |
Before login form (array) | admin.components.beforeLogin |
afterLogin |
After login form (array) | admin.components.afterLogin |
beforeNavLinks |
Before nav links (array) | admin.components.beforeNavLinks |
afterNavLinks |
After nav links (array) | admin.components.afterNavLinks |
settingsMenu |
Settings menu items (array) | admin.components.settingsMenu |
providers |
Custom React Context providers | admin.components.providers |
views |
Custom views (dashboard, etc.) | admin.components.views |
export default buildConfig({
admin: {
components: {
graphics: {
Logo: '/components/Logo',
Icon: '/components/Icon',
},
},
},
})
// components/Logo.tsx
export default function Logo() {
return <img src="/logo.png" alt="My Brand" width={200} />
}
export default buildConfig({
admin: {
components: {
actions: ['/components/ClearCacheButton', '/components/PreviewButton'],
},
},
})
// components/ClearCacheButton.tsx
'use client'
export default function ClearCacheButton() {
return (
<button
onClick={async () => {
await fetch('/api/clear-cache', { method: 'POST' })
alert('Cache cleared!')
}}
>
Clear Cache
</button>
)
}
Collection Components are specific to a collection's views.
import type { CollectionConfig } from 'payload'
export const Posts: CollectionConfig = {
slug: 'posts',
admin: {
components: {
// Edit view components
edit: {
PreviewButton: '/components/PostPreview',
SaveButton: '/components/CustomSave',
SaveDraftButton: '/components/CustomSaveDraft',
PublishButton: '/components/CustomPublish',
},
// List view components
list: {
Header: '/components/PostsListHeader',
beforeList: ['/components/ListFilters'],
afterList: ['/components/ListFooter'],
},
},
},
fields: [
// ...
],
}
Similar to Collection Components but for Global documents.
import type { GlobalConfig } from 'payload'
export const Settings: GlobalConfig = {
slug: 'settings',
admin: {
components: {
edit: {
PreviewButton: '/components/SettingsPreview',
SaveButton: '/components/SettingsSave',
},
},
},
fields: [
// ...
],
}
Customize how fields render in Edit and List views.
{
name: 'status',
type: 'select',
options: ['draft', 'published'],
admin: {
components: {
Field: '/components/StatusField',
},
},
}
// components/StatusField.tsx
'use client'
import { useField } from '@payloadcms/ui'
import type { SelectFieldClientComponent } from 'payload'
export const StatusField: SelectFieldClientComponent = ({ path, field }) => {
const { value, setValue } = useField({ path })
return (
<div>
<label>{field.label}</label>
<select value={value} onChange={(e) => setValue(e.target.value)}>
{field.options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
)
}
{
name: 'status',
type: 'select',
options: ['draft', 'published'],
admin: {
components: {
Cell: '/components/StatusCell',
},
},
}
// components/StatusCell.tsx
import type { SelectFieldCellComponent } from 'payload'
export const StatusCell: SelectFieldCellComponent = ({ data, cellData }) => {
const isPublished = cellData === 'published'
return (
<span
style={{
color: isPublished ? 'green' : 'orange',
fontWeight: 'bold',
}}
>
{cellData}
</span>
)
}
Special field type for adding custom UI without affecting data:
{
name: 'refundButton',
type: 'ui',
admin: {
components: {
Field: '/components/RefundButton',
},
},
}
// components/RefundButton.tsx
'use client'
import { useDocumentInfo } from '@payloadcms/ui'
export default function RefundButton() {
const { id } = useDocumentInfo()
return (
<button
onClick={async () => {
await fetch(`/api/orders/${id}/refund`, { method: 'POST' })
alert('Refund processed')
}}
>
Process Refund
</button>
)
}
Payload provides many React hooks for Client Components:
'use client'
import {
useAuth, // Current user
useConfig, // Payload config (client-safe)
useDocumentInfo, // Current document info (id, slug, etc.)
useField, // Field value and setValue
useForm, // Form state and dispatch
useFormFields, // Multiple field values (optimized)
useLocale, // Current locale
useTranslation, // i18n translations
usePayload, // Local API methods
} from '@payloadcms/ui'
export function MyComponent() {
const { user } = useAuth()
const { config } = useConfig()
const { id, collection } = useDocumentInfo()
const locale = useLocale()
const { t } = useTranslation()
return <div>Hello {user?.email}</div>
}
Important: These hooks only work in Client Components within the Admin Panel context.
In Server Components:
async function MyServerComponent({ payload }) {
const { config } = payload
return <div>{config.serverURL}</div>
}
In Client Components:
'use client'
import { useConfig } from '@payloadcms/ui'
export function MyClientComponent() {
const { config } = useConfig() // Client-safe config
return <div>{config.serverURL}</div>
}
Important: Client Components receive a serializable version of the config (functions, validation, etc. are stripped).
Server Component:
import type { TextFieldServerComponent } from 'payload'
export const MyFieldComponent: TextFieldServerComponent = ({ field }) => {
return <div>Field name: {field.name}</div>
}
Client Component:
'use client'
import type { TextFieldClientComponent } from 'payload'
export const MyFieldComponent: TextFieldClientComponent = ({ clientField }) => {
// clientField has non-serializable props removed
return <div>Field name: {clientField.name}</div>
}
Server Component:
import { getTranslation } from '@payloadcms/translations'
async function MyServerComponent({ i18n }) {
const translatedTitle = getTranslation(myTranslation, i18n)
return <p>{translatedTitle}</p>
}
Client Component:
'use client'
import { useTranslation } from '@payloadcms/ui'
export function MyClientComponent() {
const { t, i18n } = useTranslation()
return (
<div>
<p>{t('namespace:key', { variable: 'value' })}</p>
<p>Language: {i18n.language}</p>
</div>
)
}
import './styles.scss'
export function MyComponent() {
return <div className="my-component">Custom Component</div>
}
// styles.scss
.my-component {
background-color: var(--theme-elevation-500);
color: var(--theme-text);
padding: var(--base);
border-radius: var(--border-radius-m);
}
@import '~@payloadcms/ui/scss';
.my-component {
@include mid-break {
background-color: var(--theme-elevation-900);
}
}
'use client'
import { useFormFields } from '@payloadcms/ui'
import type { TextFieldClientComponent } from 'payload'
export const ConditionalField: TextFieldClientComponent = ({ path }) => {
const showField = useFormFields(([fields]) => fields.enableFeature?.value)
if (!showField) return null
return <input type="text" />
}
'use client'
import { useState, useEffect } from 'react'
export function DataLoader() {
const [data, setData] = useState(null)
useEffect(() => {
fetch('/api/custom-data')
.then((res) => res.json())
.then(setData)
}, [])
return <div>{JSON.stringify(data)}</div>
}
import type { Payload } from 'payload'
async function RelatedPosts({ payload, id }: { payload: Payload; id: string }) {
const post = await payload.findByID({
collection: 'posts',
id,
depth: 0,
})
const related = await payload.find({
collection: 'posts',
where: {
category: { equals: post.category },
id: { not_equals: id },
},
limit: 5,
})
return (
<div>
<h3>Related Posts</h3>
<ul>
{related.docs.map((doc) => (
<li key={doc.id}>{doc.title}</li>
))}
</ul>
</div>
)
}
export default RelatedPosts
// ❌ BAD: Imports entire package
'use client'
import { Button } from '@payloadcms/ui'
// ✅ GOOD: Tree-shakeable import for frontend
import { Button } from '@payloadcms/ui/elements/Button'
Rule: In Admin Panel UI, import from @payloadcms/ui. In frontend code, use specific paths.
// ❌ BAD: Re-renders on every form change
'use client'
import { useForm } from '@payloadcms/ui'
export function MyComponent() {
const { fields } = useForm()
// Re-renders on ANY field change
}
// ✅ GOOD: Only re-renders when specific field changes
;('use client')
import { useFormFields } from '@payloadcms/ui'
export function MyComponent({ path }) {
const value = useFormFields(([fields]) => fields[path])
// Only re-renders when this field changes
}
// ✅ GOOD: No JavaScript sent to client
async function PostCount({ payload }) {
const { totalDocs } = await payload.find({
collection: 'posts',
limit: 0,
})
return <p>{totalDocs} posts</p>
}
// Only use client components when you need:
// - State (useState, useReducer)
// - Effects (useEffect)
// - Event handlers (onClick, onChange)
// - Browser APIs (localStorage, window)
Payload generates an import map at app/(payload)/admin/importMap.js that resolves all component paths.
Regenerate manually:
payload generate:importmap
Override location:
export default buildConfig({
admin: {
importMap: {
baseDir: path.resolve(dirname, 'src'),
importMapFile: path.resolve(dirname, 'app', 'custom-import-map.js'),
},
},
})
Use Payload's TypeScript types for components:
import type {
TextFieldServerComponent,
TextFieldClientComponent,
TextFieldCellComponent,
} from 'payload'
export const MyFieldComponent: TextFieldServerComponent = (props) => {
// Fully typed props
}
Cause: Dependency version mismatch between Payload packages.
Solution: Pin all @payloadcms/* packages to the exact same version:
{
"dependencies": {
"payload": "3.0.0",
"@payloadcms/ui": "3.0.0",
"@payloadcms/richtext-lexical": "3.0.0"
}
}
/path/to/file#ExportNamepayload generate:importmap to regenerate