--- tags: [payloadcms, tech-patterns] topic: payloadcms sources: - https://payloadcms.com/docs/fields/array - https://payloadcms.com/docs/fields/blocks - https://payloadcms.com/docs/fields/group - https://payloadcms.com/docs/fields/row - https://payloadcms.com/docs/fields/collapsible - https://payloadcms.com/docs/fields/tabs - https://payloadcms.com/docs/fields/relationship - https://payloadcms.com/docs/fields/upload - https://payloadcms.com/docs/fields/rich-text - https://payloadcms.com/docs/fields/join created: 2026-05-15 --- # 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|Fields: Basic]] for scalar fields. See [[wiki/payloadcms/configuration|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. ```ts 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: ```tsx 'use client' import { useRowLabel } from '@payloadcms/ui' export const ArrayRowLabel = () => { const { data, rowNumber } = useRowLabel<{ title?: string }>() return
{data.title || `Slide ${rowNumber}`}
} ``` **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`. ```ts 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: ```ts { 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). ```ts // 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. ```ts 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. ```ts 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. ```ts 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. ```ts // 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` | `""` | | Polymorphic, `hasMany: false` | `{ relationTo: 'users', value: '' }` | | `hasMany: true` | `["", ""]` | | Polymorphic + `hasMany: true` | `[{ relationTo: 'users', value: '' }]` | **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|Join]] field. --- ### Upload Like Relationship but scoped to Upload-enabled collections. Renders a thumbnail preview. ```ts 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. ```ts 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. ```ts // 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:** ```json { "relatedPosts": { "docs": [{ "id": "...", "title": "..." }], "hasNextPage": false, "totalDocs": 5 } } ``` **Query-time override (Local API):** ```ts 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 custom `validate` for per-row. - **Localization on containers** — setting `localized: true` on Array, Group, or Blocks covers ALL nested fields; no need to mark each child. - **Named vs unnamed Group/Tab** — presence of `name` determines whether a nested DB object is created. - **Row/Collapsible** — purely presentational, cannot carry `name` or 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** — `maxDepth` defaults to `1`. Setting `depth: 0` in 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). ## Related - [[wiki/payloadcms/fields-basic|Fields: Basic]] - [[wiki/payloadcms/configuration|Configuration]]