417 lines
13 KiB
Markdown
417 lines
13 KiB
Markdown
---
|
|
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 <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`.
|
|
|
|
```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` | `"<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|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]]
|