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