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

9 KiB

tags topic sources created
payloadcms
tech-patterns
payloadcms
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
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

// 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/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">.