13 KiB
PayloadCMS — Fields: Complex
Overview
Complex fields define structure, layout, or relations between documents. Some are data-bearing (Array, Blocks, Group, Tabs with name, Relationship, Upload, Rich Text, Join); others are purely presentational (Row, Collapsible, unnamed Tabs).
See wiki/payloadcms/fields-basic for scalar fields. See wiki/payloadcms/configuration for where fields are declared.
Field Types Quick Reference
| Field | Type string | Key options |
|---|---|---|
| Array | array |
fields (required), minRows, maxRows, labels |
| Blocks | blocks |
blocks (required), minRows, maxRows, filterOptions |
| Group | group |
fields (required); name optional (nameless = presentational) |
| Row | row |
fields (required); presentational only, no name |
| Collapsible | collapsible |
label (required), fields (required), admin.initCollapsed |
| Tabs | tabs |
tabs (required); named tab creates nested object |
| Relationship | relationship |
relationTo (required), hasMany, filterOptions, maxDepth |
| Upload | upload |
relationTo (required, must be upload collection), hasMany |
| Rich Text | richText |
editor (defaults to Lexical), content saved as JSON |
| Join | join |
collection + on (required); virtual — no new DB column |
Each Field
Array
Repeating rows of fields. Each row gets an auto-generated id. Useful for sliders, nav items, agenda timeslots.
import type { Field } from 'payload'
const sliderField: Field = {
name: 'slides',
type: 'array',
minRows: 1,
maxRows: 10,
labels: { singular: 'Slide', plural: 'Slides' },
interfaceName: 'Slide',
fields: [
{ name: 'title', type: 'text' },
{ name: 'image', type: 'upload', relationTo: 'media', required: true },
{ name: 'caption', type: 'text' },
],
admin: {
initCollapsed: false,
isSortable: true,
components: {
RowLabel: '/path/to/ArrayRowLabel', // custom per-row label
},
},
}
Custom row label pattern:
'use client'
import { useRowLabel } from '@payloadcms/ui'
export const ArrayRowLabel = () => {
const { data, rowNumber } = useRowLabel<{ title?: string }>()
return <div>{data.title || `Slide ${rowNumber}`}</div>
}
Gotcha: unique: true on a nested field = collection-wide index (not per-document). Use validate for per-document uniqueness. Localization on the array field covers all nested fields — no need to mark each child localized.
Blocks
Array of heterogeneous typed objects. The classic "page builder" field. Each block has a slug (stored as blockType) and its own fields.
import type { Block, Field } from 'payload'
const HeroBlock: Block = {
slug: 'hero',
interfaceName: 'HeroBlock',
labels: { singular: 'Hero', plural: 'Heroes' },
fields: [
{ name: 'heading', type: 'text', required: true },
{ name: 'image', type: 'upload', relationTo: 'media' },
],
}
const layoutField: Field = {
name: 'layout',
type: 'blocks',
minRows: 1,
maxRows: 20,
blocks: [HeroBlock],
filterOptions: ({ siblingData }) =>
siblingData?.allowedBlocks?.length
? siblingData.allowedBlocks
: true,
}
Block references — define a block once in root buildConfig({ blocks: [...] }) and reference by slug to avoid sending duplicate config to the client:
{ name: 'content', type: 'blocks', blockReferences: ['HeroBlock'], blocks: [] }
Built-in copy/paste: Row-level and field-level via localStorage (_payloadClipboard). IDs auto-regenerated on paste.
Lexical customization: admin.components.Label / admin.components.Block on the block config to render custom previews inside Lexical editor.
Gotcha: blockReferences blocks are isolated — access control runs once, collection data unavailable. Pasting at field level replaces ALL blocks.
Group
Nests fields under a single property name (named group) or visually groups without affecting data shape (nameless group).
// Named — data stored as { pageMeta: { title, description } }
const metaGroup: Field = {
name: 'pageMeta',
type: 'group',
label: 'Page Meta',
interfaceName: 'Meta',
fields: [
{ name: 'title', type: 'text', required: true, minLength: 20, maxLength: 100 },
{ name: 'description', type: 'textarea', required: true, minLength: 40, maxLength: 160 },
],
admin: { hideGutter: false },
}
// Nameless — fields remain at parent level, just visually grouped
const presentationalGroup: Field = {
label: 'Addresses',
type: 'group',
fields: [
{ name: 'street', type: 'text' },
{ name: 'city', type: 'text' },
],
}
localized: true on the group covers all nested fields — no need to mark each child.
Row
Purely presentational — renders children side-by-side in Admin. No name, no DB column.
const nameRow: Field = {
type: 'row',
fields: [
{ name: 'firstName', type: 'text', admin: { width: '50%' } },
{ name: 'lastName', type: 'text', admin: { width: '50%' } },
],
}
No data impact. Use admin.width on children to control proportions.
Collapsible
Presentational — wraps fields in a collapsible panel. No name, no DB column.
const seoCollapsible: Field = {
type: 'collapsible',
label: ({ data }) => data?.seoTitle || 'SEO Settings', // dynamic label
fields: [
{ name: 'seoTitle', type: 'text' },
{ name: 'seoDescription', type: 'textarea' },
],
admin: { initCollapsed: true },
}
label can be a string, function, or React component — receives { data, path }.
Tabs
Groups fields into a tabbed layout. Tabs with name create a nested data object; unnamed tabs are presentational.
const contentTabs: Field = {
type: 'tabs',
tabs: [
{
label: 'Content', // unnamed → fields at root level
description: 'Main content fields',
fields: [
{ name: 'title', type: 'text', required: true },
{ name: 'body', type: 'richText' },
],
},
{
name: 'seo', // named → data.seo.metaTitle etc.
label: 'SEO',
interfaceName: 'SEOTab',
fields: [
{ name: 'metaTitle', type: 'text' },
{ name: 'metaDesc', type: 'textarea' },
],
},
{
label: 'Advanced',
admin: {
condition: (data) => data?.isAdvancedUser === true, // conditional tab
},
fields: [{ name: 'customCode', type: 'code' }],
},
],
}
When a conditional tab becomes hidden, Payload auto-switches to the next visible tab. Named tabs with interfaceName generate reusable TypeScript interfaces.
Relationship
References documents in other collections. The backbone of relational data modeling.
// Single relation
const authorField: Field = {
name: 'author',
type: 'relationship',
relationTo: 'users',
required: true,
admin: {
allowCreate: false,
allowEdit: true,
sortOptions: { users: '-createdAt' },
},
}
// Polymorphic + hasMany
const ownersField: Field = {
name: 'owners',
type: 'relationship',
relationTo: ['users', 'organizations'],
hasMany: true,
filterOptions: ({ relationTo, siblingData }) => {
if (relationTo === 'users') return { active: { equals: true } }
return true
},
admin: {
isSortable: true,
appearance: 'drawer', // 'select' | 'drawer'
},
}
Data shape by configuration:
| Config | Stored value |
|---|---|
relationTo: 'users', hasMany: false |
"<id>" |
Polymorphic, hasMany: false |
{ relationTo: 'users', value: '<id>' } |
hasMany: true |
["<id>", "<id>"] |
Polymorphic + hasMany: true |
[{ relationTo: 'users', value: '<id>' }] |
Gotcha: Polymorphic relationship fields cannot be queried on nested field values — only field.value (ID) and field.relationTo (slug) are queryable.
Use maxDepth to cap population depth independently of global depth. For bi-directional UI, combine with wiki/payloadcms/fields-complex field.
Upload
Like Relationship but scoped to Upload-enabled collections. Renders a thumbnail preview.
const heroImageField: Field = {
name: 'heroImage',
type: 'upload',
relationTo: 'media', // must be an upload collection
required: true,
displayPreview: true,
filterOptions: {
mimeType: { contains: 'image' },
},
}
// Multi-collection + hasMany
const assetsField: Field = {
name: 'assets',
type: 'upload',
relationTo: ['images', 'documents'],
hasMany: true,
}
filterOptions supports both a Where query object or a function (same signature as Relationship). Use it to restrict MIME types, sizes, or custom metadata fields.
Gotcha: The target collection must have upload: true in its config — a regular collection will error.
Rich Text
Stores rich content as JSON (Lexical by default). The editor is swappable — deep customization via editor config.
import { lexicalEditor, BlocksFeature, HeadingFeature } from '@payloadcms/richtext-lexical'
const bodyField: Field = {
name: 'body',
type: 'richText',
required: true,
localized: true,
editor: lexicalEditor({
features: [
HeadingFeature({ enabledHeadingSizes: ['h1', 'h2', 'h3'] }),
BlocksFeature({ blocks: [QuoteBlock, CallToActionBlock] }),
],
}),
}
Content is stored as JSON — serialize to HTML/Markdown in your frontend using Payload's converters (@payloadcms/richtext-lexical/html).
Gotcha: There is no default editor configured at the field level — you must set one globally in buildConfig({ editor: lexicalEditor() }) or per-field.
Join
Virtual reverse-relationship. No new column is created; Payload queries related documents at read time using MongoDB aggregations or SQL joins.
// On Categories collection — surfaces related Posts
const relatedPostsField: Field = {
name: 'relatedPosts',
type: 'join',
collection: 'posts', // the collection that has the relationship field
on: 'category', // the field name on 'posts' that points to categories
maxDepth: 1,
defaultLimit: 10,
defaultSort: '-createdAt',
where: { status: { equals: 'published' } }, // permanent filter
admin: {
defaultColumns: ['title', 'status', 'createdAt'],
allowCreate: true,
},
}
Response shape:
{
"relatedPosts": {
"docs": [{ "id": "...", "title": "..." }],
"hasNextPage": false,
"totalDocs": 5
}
}
Query-time override (Local API):
await payload.find({
collection: 'categories',
joins: {
relatedPosts: { limit: 5, sort: 'title' },
},
})
Polymorphic join: set collection to an array of slugs. Response wraps each doc as { relationTo, value }.
Gotcha: Not supported on DocumentDB or Azure Cosmos DB (limited aggregation support). where queries on joined docs inside arrays/blocks are limited in current version.
orderable: orderable: true enables drag-and-drop reordering using fractional indexing on the join result.
Gotchas
- Array/Blocks nested
unique: true— collection-wide index, not per-row. Use customvalidatefor per-row. - Localization on containers — setting
localized: trueon Array, Group, or Blocks covers ALL nested fields; no need to mark each child. - Named vs unnamed Group/Tab — presence of
namedetermines whether a nested DB object is created. - Row/Collapsible — purely presentational, cannot carry
nameor contribute to data shape. - Blocks copy/paste — "Paste Fields" replaces ALL blocks even when a single row is on clipboard. Use "Paste Row" to add alongside existing blocks.
- Upload
relationTo— must point to an upload-enabled collection; regular collections error. - Rich Text — stored as JSON; always needs a serializer to produce HTML on the frontend.
- Join —
maxDepthdefaults to1. Settingdepth: 0in the API request skips join population entirely (good for performance). - blockReferences — referenced blocks cannot be customized per-collection; access control is also run once (no collection context).