Building Custom Admin UI in Payload CMS v3: A Complete Guide to @payloadcms/ui Components

Use the undocumented @payloadcms/ui package to ship custom fields that feel native to Payload.

·Matija Žiberna·
Building Custom Admin UI in Payload CMS v3: A Complete Guide to @payloadcms/ui Components

📚 Comprehensive Payload CMS Guides

Detailed Payload guides with field configuration examples, custom components, and workflow optimization tips to speed up your CMS development process.

No spam. Unsubscribe anytime.

When I started building custom fields for Payload CMS v3, I quickly hit a wall. The @payloadcms/ui package exists, it's actively maintained, and it's right there in my node_modules. But there's no official documentation. Zero. I spent hours digging through TypeScript definitions, inspecting demo projects, and scrolling through Discord threads trying to figure out which components were available and how to use them.

After successfully building a production-ready bulk inventory field component, I realized this knowledge needed to be shared. This guide shows you exactly how to discover and use Payload's native UI components to build custom admin interfaces that look and feel like they belong in Payload. By the end, you'll know how to explore the UI package yourself and build professional custom fields without fighting the admin panel's design system. If you also need a fast iteration setup while you prototype these components, I walk through the shared-database workflow in How to Set Up Payload CMS for Instant Development Iteration and Live Preview on Vercel.

The Problem: Custom Fields That Don't Belong

I needed to build a bulk entry component for inventory batches. Users needed to add multiple products with variants, delivery dates, stock quantities, and batch numbers all in one form. A simple array field wouldn't cut it, and building everything with basic HTML inputs would look terrible and feel disconnected from Payload's polish.

The challenge wasn't just making it work functionally. It was making it feel native. Custom components built with raw HTML inputs stick out like sore thumbs in Payload's admin panel. Different spacing, inconsistent button styles, mismatched form controls. It screams "third-party component" and creates a jarring user experience.

I knew Payload must have reusable UI components internally. After all, every select dropdown, every collapsible section, every button in the admin panel uses consistent styling. But without documentation, I needed to reverse-engineer the entire UI package.

Discovering Available Components

The first step was understanding what components actually existed. I navigated to my project's node_modules and found the @payloadcms/ui package structure:

node_modules/@payloadcms/ui/dist/
├── elements/
│   ├── Button/
│   ├── Collapsible/
│   ├── ReactSelect/
│   ├── DatePicker/
│   ├── DraggableSortable/
│   └── ...
└── fields/
    ├── Text/
    ├── Number/
    ├── Select/
    ├── FieldLabel/
    ├── FieldDescription/
    └── ...

The package is organized into two main categories. The elements directory contains building blocks like buttons, modals, and interactive components. The fields directory contains complete field components and their subcomponents like labels and descriptions.

Each component folder includes TypeScript definition files. These .d.ts files became my documentation. For example, checking Button/types.d.ts revealed all available props:

// From @payloadcms/ui/dist/elements/Button/types.d.ts
export type Props = {
  buttonStyle?: 'error' | 'icon-label' | 'none' | 'pill' | 'primary' | 'secondary' | 'subtle' | 'tab' | 'transparent';
  children?: React.ReactNode;
  disabled?: boolean;
  icon?: ['chevron' | 'edit' | 'plus' | 'x'] | React.ReactNode;
  iconPosition?: 'left' | 'right';
  iconStyle?: 'none' | 'with-border' | 'without-border';
  onClick?: (event: MouseEvent) => void;
  size?: 'large' | 'medium' | 'small' | 'xsmall';
  // ... more props
}

This pattern worked for every component. The type definitions tell you exactly what props are available, what values they accept, and often include helpful comments. It's not as good as real documentation, but it's comprehensive.

Building the Foundation: Imports and Structure

I started my custom field component by importing the core UI components I'd need. The imports come directly from specific paths within the UI package:

// File: src/components/fields/InventoryBatchBulkTable.tsx
'use client'

import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { useField } from '@payloadcms/ui'
import { Button } from '@payloadcms/ui/elements/Button'
import { FieldLabel } from '@payloadcms/ui/fields/FieldLabel'
import { FieldDescription } from '@payloadcms/ui/fields/FieldDescription'
import { Collapsible } from '@payloadcms/ui/elements/Collapsible'
import { ReactSelect } from '@payloadcms/ui/elements/ReactSelect'
import type { Option } from '@payloadcms/ui/elements/ReactSelect'
import { TextInput } from '@payloadcms/ui/fields/Text'
import { DatePickerField } from '@payloadcms/ui/elements/DatePicker'
import { DraggableSortable } from '@payloadcms/ui/elements/DraggableSortable'

The useField hook is essential for any custom field component. It connects your component to Payload's form state management, giving you access to the field's value and a setter function. This is how your custom component integrates with Payload's data flow:

const { value, setValue } = useField<BulkRowPayload[]>({ path: 'bulkRows' })

This hook manages the connection between your UI component and the form data. When you call setValue, Payload updates the form state. When the form loads existing data, value provides it to your component. The type parameter ensures type safety for your specific data structure.

Using Native Form Components

One of the biggest wins was replacing basic HTML inputs with Payload's native form components. Instead of fighting with custom styling, these components automatically match the admin panel's design system.

Select Dropdowns with ReactSelect

Payload uses react-select internally, but wraps it with their own component that handles styling and behavior. Here's how I replaced a basic HTML select:

// File: src/components/fields/InventoryBatchBulkTable.tsx
<FieldLabel label="Product" />
<ReactSelect
  value={
    row.productId
      ? { label: row.productLabel || `Product ${row.productId}`, value: row.productId }
      : undefined
  }
  onChange={(selected) => {
    const option = selected as Option<number> | null
    if (!option || !option.value) {
      // Handle clearing
      return
    }
    const nextProductId = option.value as number
    // Update state
  }}
  options={products.map((product) => ({
    label: product.title,
    value: product.id,
  }))}
  isClearable
  isSearchable
  placeholder="Select product…"
/>

The ReactSelect component provides built-in search functionality, keyboard navigation, and a clear button. The options format is straightforward: an array of objects with label and value properties. The component handles all the complex dropdown behavior while maintaining Payload's visual style.

What makes this powerful is how it handles the value. You pass an option object matching the format of your options array. When the user changes the selection, onChange receives the selected option object. For clearing, it receives null. This consistent API makes it predictable to work with.

Text Inputs with Proper Styling

For text and number inputs, Payload provides TextInput components that match the admin panel's input styling:

// File: src/components/fields/InventoryBatchBulkTable.tsx
<FieldLabel label="Total stock" />
<TextInput
  path={`${row.id}-totalStock`}
  value={row.totalStock?.toString() ?? ''}
  onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
    const nextValue = e.target.value
    updateRow(row.id, (current) => ({
      ...current,
      totalStock: nextValue === '' ? undefined : Number(nextValue),
    }))
  }}
  placeholder="Enter quantity"
/>

The TextInput component requires a path prop for Payload's field tracking system. Even though this is a custom component managing its own state, providing unique paths for each input ensures proper React key management and helps with debugging. The onChange handler receives a standard React change event, making it familiar to work with.

Date Pickers with Calendar UI

Instead of basic HTML date inputs, Payload provides DatePickerField with a full calendar interface:

// File: src/components/fields/InventoryBatchBulkTable.tsx
<FieldLabel label="Received date" />
<DatePickerField
  value={row.receivedDate}
  onChange={(date) => {
    const dateValue = date ? formatDateForInput(date) : todayInputValue
    updateRow(row.id, (current) => ({
      ...current,
      receivedDate: dateValue,
    }))
  }}
  placeholder="Select received date"
/>

The DatePickerField handles date parsing and formatting automatically. It accepts both Date objects and ISO strings as values, and the onChange callback receives a Date object. This eliminates the need to manually handle date string formatting and provides users with a much better experience than typing dates into a text field.

Labels and Descriptions

Every field needs proper labels and helper text. Payload provides dedicated components that ensure consistent typography and spacing:

// File: src/components/fields/InventoryBatchBulkTable.tsx
<FieldLabel label="Unit" />
<ReactSelect /* ... */ />
{productOption && (
  <FieldDescription
    path={`${row.id}-unit-locked`}
    description="Unit is set by the product configuration"
  />
)}

The FieldLabel component handles proper label styling and positioning. It's a simple wrapper, but using it ensures your labels match every other field in Payload. The FieldDescription component displays helper text below fields with appropriate styling and color. Like TextInput, it requires a path prop for proper component management.

These small details matter. When every part of your custom field uses the same label fonts, description colors, and spacing as native Payload fields, the component feels integrated rather than bolted on.

Organizing Content with Collapsible Sections

For complex forms with many fields, collapsible sections improve usability. Payload's Collapsible component provides exactly this:

// File: src/components/fields/InventoryBatchBulkTable.tsx
<Collapsible
  key={row.id}
  initCollapsed={false}
  header={<span>{rowTitle}</span>}
>
  <div
    style={{
      display: 'grid',
      gridTemplateColumns: 'repeat(6, minmax(0, 1fr))',
      gap: 12,
      padding: 12,
    }}
  >
    {/* All form fields */}
  </div>
</Collapsible>

The Collapsible component wraps content that can be toggled. The header prop defines what's always visible, and children define the collapsible content. Setting initCollapsed={false} means sections start expanded. Users can click the header to collapse sections they're not actively editing, reducing visual clutter on forms with many rows.

The component includes all the necessary accessibility attributes and keyboard handling automatically. The expand and collapse animations are smooth and match the rest of Payload's interface. This creates a professional feel that would take significant custom CSS to replicate.

Buttons with Consistent Styling

Payload provides a comprehensive Button component with multiple style variants:

// File: src/components/fields/InventoryBatchBulkTable.tsx
<Button
  buttonStyle="secondary"
  size="small"
  onClick={() => duplicateRow(row.id)}
>
  Duplicate Row
</Button>

<Button
  buttonStyle="error"
  size="small"
  onClick={() => removeRow(row.id)}
  disabled={rows.length <= 1}
>
  Remove Row
</Button>

The buttonStyle prop offers multiple variants including primary, secondary, error, and subtle. These variants have predefined colors and hover states that match Payload's design system. The size prop controls button dimensions with options for small, medium, and large.

For primary actions like adding new rows, you can include icons:

// File: src/components/fields/InventoryBatchBulkTable.tsx
<Button
  buttonStyle="primary"
  size="medium"
  icon="plus"
  iconPosition="left"
  onClick={addRow}
>
  Add another row
</Button>

The icon prop accepts preset icon names like plus, edit, chevron, and x. The iconPosition prop controls whether icons appear before or after the button text. This built-in icon system means you don't need to import or configure icon libraries.

Advanced Layout: DraggableSortable Lists

One of the most impressive components I discovered was DraggableSortable. It provides drag-and-drop reordering with proper spacing and visual feedback:

// File: src/components/fields/InventoryBatchBulkTable.tsx
<DraggableSortable
  className="array-field__draggable-rows"
  ids={rows.map((row) => row.id)}
  onDragEnd={({ moveFromIndex, moveToIndex }) => {
    setRows((prevRows) => {
      const newRows = [...prevRows]
      const [movedRow] = newRows.splice(moveFromIndex, 1)
      newRows.splice(moveToIndex, 0, movedRow)
      return newRows
    })
  }}
>
  {renderRows()}
</DraggableSortable>

The component requires an array of unique IDs matching your list items and an onDragEnd callback that receives the source and destination indices. The callback provides exactly what you need to reorder your state array. The component handles all the drag-and-drop interaction, visual feedback during dragging, and accessibility keyboard shortcuts for reordering.

The className prop is important here. Setting it to array-field__draggable-rows applies Payload's native array field spacing. This ensures proper gaps between items that match how native array fields look throughout the admin panel.

Using Native CSS Classes

Payload includes extensive SCSS stylesheets for its components. Rather than writing custom CSS, you can leverage these existing classes to ensure your component matches the admin panel's design:

// File: src/components/fields/InventoryBatchBulkTable.tsx
return (
  <div className="array-field">
    <div className="array-field__header">
      <FieldLabel label="Bulk inventory batches" />
      <FieldDescription
        path="bulkRows"
        description="Add one row per product (variant) you produced."
      />
    </div>

    <DraggableSortable className="array-field__draggable-rows">
      {renderRows()}
    </DraggableSortable>

    <Button className="array-field__add-row">
      Add another row
    </Button>
  </div>
)

These CSS classes come from Payload's array field implementation. The array-field class provides the container styling. The array-field__header class handles spacing and layout for the field's title and description. The array-field__draggable-rows class adds proper gaps between items using CSS custom properties.

Looking at Payload's SCSS reveals how these classes work:

.array-field {
  display: flex;
  flex-direction: column;
  gap: calc(var(--base) / 2);

  &__draggable-rows {
    display: flex;
    flex-direction: column;
    gap: calc(var(--base) / 2);
  }

  &__add-row {
    align-self: flex-start;
    margin: 2px 0;
  }
}

The classes use CSS custom properties like --base that Payload defines globally. This means your component automatically adapts if Payload's spacing system changes. The gaps between items, the margins on buttons, and the overall layout all stay consistent with the rest of the admin panel.

Fetching Cross-Collection Data

Custom fields often need data from other collections. My inventory component needed products, variants, and delivery dates. The key is handling this data fetching in a way that doesn't block the component from rendering. When those components also have to write derived data back through hooks, pair the UI work with the transaction-safe patterns from How to Safely Manipulate Payload CMS Data in Hooks Without Hanging or Recursion:

// File: src/components/fields/InventoryBatchBulkTable.tsx
const useOptions = <T,>(endpoint: string, map: (doc: any) => T) => {
  const [options, setOptions] = useState<T[]>([])
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState<string | null>(null)

  useEffect(() => {
    let isMounted = true
    const controller = new AbortController()

    const load = async () => {
      setLoading(true)
      setError(null)
      try {
        const res = await fetch(endpoint, { signal: controller.signal })
        if (!res.ok) {
          throw new Error(`Failed to load options (${res.status})`)
        }
        const data = await res.json()
        const docs = Array.isArray(data?.docs)
          ? data.docs
          : Array.isArray(data?.data)
          ? data.data
          : []

        if (isMounted) {
          setOptions(docs.map(map))
        }
      } catch (err) {
        if (isMounted) {
          setError(err instanceof Error ? err.message : 'Unknown error')
        }
      } finally {
        if (isMounted) {
          setLoading(false)
        }
      }
    }

    void load()

    return () => {
      isMounted = false
      controller.abort()
    }
  }, [endpoint, map])

  return { options, loading, error }
}

This custom hook handles the complete lifecycle of fetching collection data. The AbortController ensures requests are cancelled if the component unmounts before they complete. The isMounted flag prevents state updates on unmounted components. The generic type parameter allows the hook to work with any data structure.

The hook handles both Payload's standard API format with a docs array and custom API routes that might return data in a different structure. This flexibility is important because you might need to use custom routes for complex queries or filtering.

Using the hook is straightforward:

// File: src/components/fields/InventoryBatchBulkTable.tsx
const {
  options: products,
  loading: productsLoading,
  error: productsError,
} = useOptions<ProductOption>(
  '/api/products?limit=500&depth=0',
  (doc) => ({
    id: doc.id,
    title: doc.title,
    hasVariants: Boolean(doc.hasVariants),
    inventoryAllocationMode: doc.inventoryAllocationMode,
    quantityUnit: doc.quantityUnit,
  })
)

The map function transforms raw API responses into exactly the shape your component needs. This keeps your component logic clean and separates data transformation from UI concerns.

Handling Loading and Error States

With cross-collection data fetching, you need to handle loading and error states gracefully:

// File: src/components/fields/InventoryBatchBulkTable.tsx
const renderStatus = () => {
  if (productsLoading || deliveryDatesLoading) {
    return (
      <div className="field-type-text" style={{ marginTop: 8, color: '#666' }}>
        Loading reference data…
      </div>
    )
  }
  if (productsError) {
    return (
      <div className="field-type-text" style={{ color: '#b91c1c', marginTop: 8 }}>
        Failed to load products: {productsError}
      </div>
    )
  }
  if (deliveryDatesError) {
    return (
      <div className="field-type-text" style={{ color: '#b91c1c', marginTop: 8 }}>
        Failed to load delivery dates: {deliveryDatesError}
      </div>
    )
  }
  return null
}

The status rendering function provides feedback without blocking the entire component. Users see the form structure immediately, with loading indicators or error messages appearing inline. The field-type-text class ensures status messages use appropriate text styling that matches other field descriptions in Payload.

This approach is better than showing a full-page loading spinner or completely blocking the form. Users can see the overall structure and understand what data is loading. If one data source fails, the rest of the form remains functional.

Complete Component Structure

Here's how all these pieces come together in the final component structure:

// File: src/components/fields/InventoryBatchBulkTable.tsx
const InventoryBatchBulkTable: React.FC = () => {
  const { value, setValue } = useField<BulkRowPayload[]>({ path: 'bulkRows' })
  const [rows, setRows] = useState<BulkRowState[]>(/* initial state */)

  // Fetch reference data
  const { options: products } = useOptions(/* ... */)
  const { options: deliveryDates } = useOptions(/* ... */)

  // Sync local state with Payload form state
  useEffect(() => {
    const payloadRows = rows.map(mapRowToPayload)
    setValue(payloadRows)
  }, [rows, setValue])

  const updateRow = useCallback((rowId: string, updater) => {
    setRows((prev) => prev.map((row) =>
      row.id === rowId ? updater(row) : row
    ))
  }, [])

  const addRow = () => {
    setRows((prev) => [...prev, { id: `row-${Date.now()}`, /* ... */ }])
  }

  const duplicateRow = (rowId: string) => {
    setRows((prev) => {
      const row = prev.find((r) => r.id === rowId)
      return row ? [...prev, { ...row, id: `row-${Date.now()}` }] : prev
    })
  }

  const removeRow = (rowId: string) => {
    setRows((prev) => prev.length <= 1 ? prev : prev.filter((r) => r.id !== rowId))
  }

  return (
    <div className="array-field">
      <div className="array-field__header">
        <FieldLabel label="Bulk inventory batches" />
        <FieldDescription path="bulkRows" description="..." />
      </div>

      {renderStatus()}

      <DraggableSortable
        className="array-field__draggable-rows"
        ids={rows.map((row) => row.id)}
        onDragEnd={({ moveFromIndex, moveToIndex }) => {
          /* reorder logic */
        }}
      >
        {rows.map((row) => (
          <Collapsible key={row.id} header={/* ... */}>
            {/* Form fields using ReactSelect, TextInput, DatePickerField */}
            {/* Action buttons using Button component */}
          </Collapsible>
        ))}
      </DraggableSortable>

      <Button buttonStyle="primary" icon="plus" onClick={addRow}>
        Add another row
      </Button>
    </div>
  )
}

The component manages local state for immediate UI updates and syncs with Payload's form state through the useField hook. This pattern provides instant feedback to users while ensuring data is properly saved when the form submits. The native UI components handle all styling and behavior, leaving you to focus on business logic.

The Results

Building custom fields with Payload's native UI components creates interfaces that feel completely integrated with the admin panel. Users can't tell where Payload ends and custom components begin. The consistent styling, familiar interaction patterns, and proper spacing make the experience seamless.

You now have a complete approach for building custom Payload fields. You know how to discover available components by exploring the node_modules directory and reading TypeScript definitions. You understand how to use the core UI components for forms, layouts, and interactions. You can leverage Payload's CSS classes to ensure your components match the design system without writing custom styles.

The lack of official documentation for @payloadcms/ui is frustrating, but it doesn't have to stop you from building professional custom admin interfaces. The components are there, they're well-designed, and with the exploration approach I've shared, you can use them effectively.

If you have questions about specific components or run into challenges implementing custom fields, let me know in the comments. And subscribe for more practical guides on working with Payload CMS and modern web development tools.

Thanks, Matija

0

Comments

Leave a Comment

Your email will not be published

10-2000 characters

• Comments are automatically approved and will appear immediately

• Your name and email will be saved for future comments

• Be respectful and constructive in your feedback

• No spam, self-promotion, or off-topic content

Matija Žiberna
Matija Žiberna
Full-stack developer, co-founder

I'm Matija Žiberna, a self-taught full-stack developer and co-founder passionate about building products, writing clean code, and figuring out how to turn ideas into businesses. I write about web development with Next.js, lessons from entrepreneurship, and the journey of learning by doing. My goal is to provide value through code—whether it's through tools, content, or real-world software.

You might be interested in