---
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]]