Payload CMSCustom FieldsBlocksAdmin InterfaceReact

Extending Payload CMS Admin with Custom Fields and Blocks

Learn how to create custom fields and blocks in Payload CMS to build powerful, tailored admin interfaces that match your specific content requirements.

ByDevelopment Team
8 min read

Extending Payload CMS Admin with Custom Fields and Blocks

One of Payload CMS's greatest strengths is its extensibility. While the built-in field types cover most use cases, there are times when you need something more specific to your project. This guide will walk you through creating custom fields and blocks that extend Payload's admin interface with your own React components.

Understanding Payload's Field Architecture

Before diving into custom fields, it's important to understand how Payload structures its field system:

// Basic field structure
{
  name: 'fieldName',
  type: 'text', // Built-in type
  admin: {
    // Admin-specific configuration
  },
  // Validation, hooks, etc.
}

Creating Custom Field Types

1. Basic Custom Field Component

Let's start with a simple color picker field:

// components/ColorPicker/index.tsx
import React from 'react'
import { useField, withCondition } from 'payload/components/forms'

const ColorPickerField: React.FC<any> = ({ path, name, label, required }) => {
  const { value, setValue } = useField<string>({ path: path || name })

  return (
    <div className="field-type color-picker">
      <label className="field-label">
        {label}
        {required && <span className="required">*</span>}
      </label>
      <div className="color-picker-container">
        <input
          type="color"
          value={value || '#000000'}
          onChange={(e) => setValue(e.target.value)}
          className="color-input"
        />
        <input
          type="text"
          value={value || ''}
          onChange={(e) => setValue(e.target.value)}
          placeholder="#000000"
          className="color-text-input"
        />
      </div>
    </div>
  )
}

export default withCondition(ColorPickerField)

2. Registering the Custom Field

// payload.config.ts
import ColorPickerField from './components/ColorPicker'

export default buildConfig({
  admin: {
    components: {
      fields: {
        colorPicker: ColorPickerField,
      },
    },
  },
  // ... rest of config
})

3. Using the Custom Field

// collections/Themes.ts
{
  slug: 'themes',
  fields: [
    {
      name: 'primaryColor',
      type: 'text', // Store as text in database
      admin: {
        components: {
          Field: 'colorPicker', // Use our custom component
        },
      },
    },
  ],
}

Advanced Custom Fields

1. Multi-Value Custom Field

Create a tag input field that allows multiple values:

// components/TagInput/index.tsx
import React, { useState } from 'react'
import { useField } from 'payload/components/forms'

const TagInputField: React.FC<any> = ({ path, name, label }) => {
  const { value = [], setValue } = useField<string[]>({ path: path || name })
  const [inputValue, setInputValue] = useState('')

  const addTag = () => {
    if (inputValue.trim() && !value.includes(inputValue.trim())) {
      setValue([...value, inputValue.trim()])
      setInputValue('')
    }
  }

  const removeTag = (tagToRemove: string) => {
    setValue(value.filter(tag => tag !== tagToRemove))
  }

  const handleKeyDown = (e: React.KeyboardEvent) => {
    if (e.key === 'Enter') {
      e.preventDefault()
      addTag()
    }
  }

  return (
    <div className="field-type tag-input">
      <label className="field-label">{label}</label>
      <div className="tag-container">
        {value.map((tag, index) => (
          <span key={index} className="tag">
            {tag}
            <button
              type="button"
              onClick={() => removeTag(tag)}
              className="tag-remove"
            >
              ×
            </button>
          </span>
        ))}
      </div>
      <div className="tag-input-container">
        <input
          type="text"
          value={inputValue}
          onChange={(e) => setInputValue(e.target.value)}
          onKeyDown={handleKeyDown}
          placeholder="Add a tag..."
          className="tag-input-field"
        />
        <button type="button" onClick={addTag} className="tag-add-btn">
          Add
        </button>
      </div>
    </div>
  )
}

export default TagInputField

2. Custom Field with External API

Create a field that fetches data from an external service:

// components/GeolocationField/index.tsx
import React, { useState, useCallback } from 'react'
import { useField } from 'payload/components/forms'

interface Location {
  lat: number
  lng: number
  address: string
}

const GeolocationField: React.FC<any> = ({ path, name, label }) => {
  const { value, setValue } = useField<Location>({ path: path || name })
  const [loading, setLoading] = useState(false)
  const [error, setError] = useState('')

  const geocodeAddress = useCallback(async (address: string) => {
    setLoading(true)
    setError('')
    
    try {
      const response = await fetch(
        `https://api.mapbox.com/geocoding/v5/mapbox.places/${encodeURIComponent(address)}.json?access_token=${process.env.NEXT_PUBLIC_MAPBOX_TOKEN}`
      )
      const data = await response.json()
      
      if (data.features && data.features.length > 0) {
        const feature = data.features[0]
        setValue({
          lat: feature.center[1],
          lng: feature.center[0],
          address: feature.place_name,
        })
      } else {
        setError('Location not found')
      }
    } catch (err) {
      setError('Failed to geocode address')
    } finally {
      setLoading(false)
    }
  }, [setValue])

  return (
    <div className="field-type geolocation">
      <label className="field-label">{label}</label>
      <div className="geolocation-container">
        <input
          type="text"
          placeholder="Enter an address..."
          onKeyDown={(e) => {
            if (e.key === 'Enter') {
              e.preventDefault()
              geocodeAddress(e.currentTarget.value)
            }
          }}
        />
        {loading && <div className="loading">Geocoding...</div>}
        {error && <div className="error">{error}</div>}
        {value && (
          <div className="location-details">
            <p><strong>Address:</strong> {value.address}</p>
            <p><strong>Coordinates:</strong> {value.lat}, {value.lng}</p>
          </div>
        )}
      </div>
    </div>
  )
}

export default GeolocationField

Creating Custom Blocks

Blocks are reusable content components that can be mixed and matched. They're perfect for building flexible page layouts.

1. Basic Custom Block

// blocks/CallToAction.ts
import { Block } from 'payload/types'

export const CallToAction: Block = {
  slug: 'callToAction',
  labels: {
    singular: 'Call to Action',
    plural: 'Call to Actions',
  },
  fields: [
    {
      name: 'title',
      type: 'text',
      required: true,
    },
    {
      name: 'subtitle',
      type: 'textarea',
    },
    {
      name: 'buttonText',
      type: 'text',
      required: true,
    },
    {
      name: 'buttonUrl',
      type: 'text',
      required: true,
    },
    {
      name: 'style',
      type: 'select',
      options: [
        { label: 'Primary', value: 'primary' },
        { label: 'Secondary', value: 'secondary' },
        { label: 'Outline', value: 'outline' },
      ],
      defaultValue: 'primary',
    },
    {
      name: 'backgroundImage',
      type: 'upload',
      relationTo: 'media',
    },
  ],
}

2. Advanced Block with Nested Fields

// blocks/FeatureGrid.ts
export const FeatureGrid: Block = {
  slug: 'featureGrid',
  labels: {
    singular: 'Feature Grid',
    plural: 'Feature Grids',
  },
  fields: [
    {
      name: 'title',
      type: 'text',
    },
    {
      name: 'features',
      type: 'array',
      minRows: 1,
      maxRows: 6,
      fields: [
        {
          name: 'icon',
          type: 'select',
          options: [
            { label: 'Star', value: 'star' },
            { label: 'Heart', value: 'heart' },
            { label: 'Lightning', value: 'lightning' },
            { label: 'Shield', value: 'shield' },
          ],
        },
        {
          name: 'title',
          type: 'text',
          required: true,
        },
        {
          name: 'description',
          type: 'textarea',
          required: true,
        },
        {
          name: 'link',
          type: 'group',
          fields: [
            {
              name: 'url',
              type: 'text',
            },
            {
              name: 'text',
              type: 'text',
            },
          ],
        },
      ],
    },
    {
      name: 'layout',
      type: 'select',
      options: [
        { label: '2 Columns', value: 'two-column' },
        { label: '3 Columns', value: 'three-column' },
        { label: '4 Columns', value: 'four-column' },
      ],
      defaultValue: 'three-column',
    },
  ],
}

3. Block with Custom Admin Component

// blocks/VideoEmbed.ts
import VideoEmbedComponent from '../components/VideoEmbed'

export const VideoEmbed: Block = {
  slug: 'videoEmbed',
  labels: {
    singular: 'Video Embed',
    plural: 'Video Embeds',
  },
  fields: [
    {
      name: 'url',
      type: 'text',
      required: true,
      admin: {
        components: {
          Field: VideoEmbedComponent,
        },
      },
    },
    {
      name: 'title',
      type: 'text',
    },
    {
      name: 'autoplay',
      type: 'checkbox',
      defaultValue: false,
    },
  ],
}

Block Preview Components

Create preview components for your blocks in the admin:

// components/BlockPreviews/CallToActionPreview.tsx
import React from 'react'

const CallToActionPreview: React.FC<any> = ({ title, subtitle, buttonText, style }) => {
  return (
    <div className={`cta-preview cta-preview--${style}`}>
      <div className="cta-content">
        {title && <h3>{title}</h3>}
        {subtitle && <p>{subtitle}</p>}
        {buttonText && (
          <button className={`btn btn--${style}`}>
            {buttonText}
          </button>
        )}
      </div>
    </div>
  )
}

export default CallToActionPreview

Register the preview component:

// payload.config.ts
export default buildConfig({
  admin: {
    components: {
      blocks: {
        CallToAction: CallToActionPreview,
      },
    },
  },
})

Advanced Block Patterns

1. Conditional Block Fields

{
  name: 'contentType',
  type: 'select',
  options: [
    { label: 'Text', value: 'text' },
    { label: 'Image', value: 'image' },
    { label: 'Video', value: 'video' },
  ],
},
{
  name: 'textContent',
  type: 'richText',
  admin: {
    condition: (data) => data.contentType === 'text',
  },
},
{
  name: 'image',
  type: 'upload',
  relationTo: 'media',
  admin: {
    condition: (data) => data.contentType === 'image',
  },
},
{
  name: 'videoUrl',
  type: 'text',
  admin: {
    condition: (data) => data.contentType === 'video',
  },
},

2. Block with Validation

{
  slug: 'testimonial',
  fields: [
    {
      name: 'quote',
      type: 'textarea',
      required: true,
      validate: (val) => {
        if (val && val.length > 500) {
          return 'Quote must be less than 500 characters'
        }
      },
    },
    {
      name: 'author',
      type: 'group',
      fields: [
        {
          name: 'name',
          type: 'text',
          required: true,
        },
        {
          name: 'title',
          type: 'text',
        },
        {
          name: 'company',
          type: 'text',
        },
      ],
    },
  ],
}

Best Practices for Custom Fields and Blocks

1. Component Organization

components/
├── fields/
│   ├── ColorPicker/
│   │   ├── index.tsx
│   │   └── styles.scss
│   └── TagInput/
│       ├── index.tsx
│       └── styles.scss
├── blocks/
│   ├── CallToAction/
│   │   ├── index.tsx
│   │   └── preview.tsx
│   └── FeatureGrid/
│       ├── index.tsx
│       └── preview.tsx

2. Type Safety

// types/blocks.ts
export interface CallToActionBlock {
  blockType: 'callToAction'
  title: string
  subtitle?: string
  buttonText: string
  buttonUrl: string
  style: 'primary' | 'secondary' | 'outline'
  backgroundImage?: string
}

// Use in your components
const CallToActionComponent: React.FC<CallToActionBlock> = ({
  title,
  subtitle,
  buttonText,
  buttonUrl,
  style,
  backgroundImage,
}) => {
  // Component implementation
}

3. Reusable Field Configurations

// fields/common.ts
export const seoFields = [
  {
    name: 'seo',
    type: 'group',
    fields: [
      {
        name: 'title',
        type: 'text',
        maxLength: 60,
      },
      {
        name: 'description',
        type: 'textarea',
        maxLength: 160,
      },
    ],
  },
]

// Use in collections
{
  slug: 'pages',
  fields: [
    // ... other fields
    ...seoFields,
  ],
}

Conclusion

Custom fields and blocks are powerful tools for extending Payload CMS to meet your specific needs. By creating reusable components that integrate seamlessly with Payload's admin interface, you can build sophisticated content management experiences that are both developer-friendly and user-friendly.

Remember to:

  • Keep components focused and reusable
  • Implement proper TypeScript types
  • Add validation where appropriate
  • Create preview components for better user experience
  • Follow consistent naming conventions

Next up: Learn how to implement dark mode themes in your Payload projects to create a more personalized admin experience.

Tagged with:Payload CMSCustom FieldsBlocksAdmin InterfaceReact
More Articles