obsidian/wiki/payloadcms/fields-complex.md
2026-05-15 15:13:56 +01:00

13 KiB

tags topic sources created
payloadcms
tech-patterns
payloadcms
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
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 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 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.
  • JoinmaxDepth 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).