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