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.