Data Modeling — Fields / Lexical / Relationships / Versions / Localization
Goals for this chapter
- Understand the Field taxonomy of PayloadCMS (Data / Presentational / Virtual, three buckets)
- Develop a working rule for relational fields (
relationship/upload) and structural ones (array/group/tabs/blocks) - Understand the default features of the Lexical Rich Text editor and how to add to them
- Be able to write the minimum config for Localization and Versions & Drafts
Where Fields live (recap)
As 02 Core Concepts covered, PayloadCMS's data model is a three-tier hierarchy:
Payload Config (payload.config.ts)
├─ Collections (repeatable / posts, users, media …) → fields[] ← this chapter
└─ Globals (singletons / header, footer …) → fields[] ← this chapter
The official docs describe Globals as "many ways similar to Collections, except that they correspond to only a single Document" (Globals). Whether the container is repeatable or a singleton, the fields array that defines its shape uses the same vocabulary — and that fields[] world is what this chapter is about.
On top of that, field types like group / array / blocks / tabs carry their own fields array, so the tree can recurse arbitrarily deep (Fields Overview). Each of those is covered in its own section below.
The three field buckets
The official docs (Fields Overview) split Fields into three groups:
| Bucket | Role | Types |
|---|---|---|
| Data Fields | Stored in the DB | text / textarea / email / number / date / select / checkbox / radio / array / blocks / group / tabs (named) / relationship / upload / richText / code / json / point |
| Presentational Fields | For the UI only — not stored | collapsible / row / tabs (unnamed) / group (unnamed) / ui |
| Virtual Fields | Derived / computed | join / any field with virtual: true |
The official docs put it directly: "Presentational Fields do not store data in the database. Instead, they are used to organize and present other fields in the Admin Panel." Data Fields require a name; presentational ones don't — that's the cleanest way to tell them apart.
Properties shared across all Fields
| Property | Meaning |
|---|---|
name | DB key (required for Data Fields) |
type | Determines field behavior (required) |
label | Admin UI display name |
required | Validation — disallow empty |
validate | Custom validation function |
hooks | Field-level lifecycle (beforeChange / afterRead / …) |
access | Field-level access control |
admin | UI customization (description / position / readOnly / …) |
defaultValue | Initial value |
localized | true for translation (see below) |
{
name: 'myField',
type: 'text',
required: true,
admin: { description: 'A simple text field' },
}
Primitive Field catalog
Minimal examples and typical use cases:
{ name: 'title', type: 'text' } // single-line text
{ name: 'body', type: 'textarea' } // multi-line text
{ name: 'email', type: 'email' } // email (with validation)
{ name: 'price', type: 'number', min: 0 } // number
{ name: 'publishedAt', type: 'date' } // date
{ name: 'status', type: 'select', options: ['draft', 'published'] } // enum
{ name: 'isFeatured', type: 'checkbox' } // boolean
{ name: 'tier', type: 'radio', options: ['free', 'pro'] }
{ name: 'config', type: 'json' } // arbitrary JSON
{ name: 'snippet', type: 'code', admin: { language: 'ts' } }
{ name: 'location', type: 'point' } // geo point [lng, lat]
Relational Fields
relationship
Reference another Collection. Supports 1:1, 1:N, and polymorphic (one of several Collections):
{
name: 'author',
type: 'relationship',
relationTo: 'users', // single Collection
required: true,
}
{
name: 'tags',
type: 'relationship',
relationTo: 'tags',
hasMany: true, // 1:N
}
{
name: 'related',
type: 'relationship',
relationTo: ['posts', 'pages'], // polymorphic
}
The Admin UI auto-grows a picker that lets you search the related Collection, and the API response can expand nested relations with depth (REST: ?depth=2; Local: payload.find({ ..., depth: 2 })).
How polymorphic relationships actually behave
Passing relationTo as an array (['users', 'organizations']) turns the field into a polymorphic relationship — a single slot that can hold documents of more than one type. Examples: a notification target that's either a User or an Organization, or a comment target that's either a Post or a Page. One field, multiple possible source Collections.
How the value is stored
This is the key point: a relationship that targets a single Collection and one that's polymorphic are stored as different shapes in the DB.
// relationTo: 'users' (single) — the stored value is a bare ID (string or number)
{
"author": "6031ac9e1289176380734024"
}
// relationTo: ['users', 'organizations'] (polymorphic)
// — the stored value is an object that carries which Collection the ID belongs to
{
"owners": [
{ "relationTo": "users", "value": "6031ac9e1289176380734024" },
{ "relationTo": "organizations", "value": "602c3c327b811235943ee12b" }
]
}
A bare ID isn't enough for polymorphic — "the id 6031ac9e…" alone can't tell you whether it points at users or organizations, so relationTo has to ride alongside it.
Complete official example
The official docs combine polymorphic + hasMany + required in one example:
{
name: 'owners',
type: 'relationship',
relationTo: ['users', 'organizations'],
hasMany: true,
required: true,
}
Branching on the frontend
Consumers (Server Components / API clients) read relationTo and branch on it when rendering. At depth: 0 the value is still an ID; at depth: 1 (or higher) the related document is expanded — this behavior is documented in Queries — Depth. The official example shows the single-relationship case; combining it with the polymorphic { relationTo, value } shape gives you the branching pattern below:
// At depth 1+, `value` is the expanded document
post.owners?.map((owner) => {
if (typeof owner.value === 'string') return null // not expanded for some reason
if (owner.relationTo === 'users') {
return <UserCard key={owner.value.id} user={owner.value} />
}
if (owner.relationTo === 'organizations') {
return <OrgCard key={owner.value.id} org={owner.value} />
}
return null
})
The types generated into @/payload-types make value a union like User | Organization | string, so the owner.relationTo === 'users' check narrows the type for TypeScript and the branches stay type-safe.
When to reach for it
- Use it: when the same slot legitimately holds documents of different kinds — notification recipients (User / Organization), related links (Post / Page), comment targets (Article / Product / Event)
- Avoid it: when only one kind is ever expected. A single
relationTo: 'users'gives you a plain string value with notypeofchecks or switch arms — leaner code
A quick rule of thumb: ask "can this ever need to accept another type of document?" If the answer is "no, never," stay single. Otherwise, go polymorphic.
upload
A reference to an Upload-enabled Collection (typically a Media collection). It's sugar over relationship for binding images / PDFs / etc.
{
name: 'thumbnail',
type: 'upload',
relationTo: 'media',
}
On the Media side, setting upload: { staticDir, imageSizes } makes Payload auto-resize images according to the imageSizes config (covered in 07 Admin / Media / Deploy).
Structural Fields
| Field | Use case |
|---|---|
group | Group fields into one object ({ name: 'seo', type: 'group', fields: [...] }) |
array | Repeat the same schema N times |
tabs | Visually split fields into tabs in the Admin UI |
blocks | Repeat different schemas N times (★ the heart of a page builder) |
blocks — the heart of a page builder
According to the official docs (Blocks), the Blocks Field stores "an array of objects with different schemas." The typical use case is "mix Quote / CallToAction / Slider / Gallery blocks in a page builder."
import type { Block } from 'payload'
const Hero: Block = {
slug: 'Hero',
fields: [
{ name: 'headline', type: 'text' },
{ name: 'image', type: 'upload', relationTo: 'media' },
],
}
const CallToAction: Block = {
slug: 'CallToAction',
fields: [
{ name: 'label', type: 'text' },
{ name: 'href', type: 'text' },
],
}
// Used inside a Collection's fields
{
name: 'layout',
type: 'blocks',
blocks: [Hero, CallToAction],
}
An "+ Add Block" button appears in the Admin UI, and editors can stack Hero and CallToAction in any order to compose a page. On the code side, you only need a 1:1 mapping of "which block renders how" — the CMS authoring flow and the frontend rendering stay cleanly separated.
How blocks differ from array (official wording): with array all items share the same schema, while blocks "lets you mix different content types in any order."
Lexical Rich Text
Where it stands
In 3.x, Lexical (built by Meta) is the default and stable Rich Text Editor. The official docs (Rich Text Overview) explicitly mark the older Slate adapter as scheduled for removal in v4.0, so new projects should pick Lexical by default. A Slate-to-Lexical migration guide is provided.
Default features
The Lexical editor is composed of "features." The main features the official docs (Official Features) mark as "Included by default: Yes" are:
- HeadingFeature — headings H1 through H6
- UnorderedListFeature / OrderedListFeature / ChecklistFeature — bullet / ordered / check lists
- BlockquoteFeature — block quotes
- LinkFeature — hyperlinks
- UploadFeature — embed images / PDFs / etc. inside the editor
- RelationshipFeature — embed a Collection document inside the editor
- HorizontalRuleFeature — horizontal rule
- plus inline formats such as Bold / Italic / Underline, and Align / Indent
BlocksFeature (embedding the same Blocks as the Blocks Field inside the editor — content within content) is not included by default ("Included by default: No"). You add it explicitly, as shown in "Adding features" below.
Adding features
You compose features via the editor property:
import { lexicalEditor, BlocksFeature, LinkFeature } from '@payloadcms/richtext-lexical'
// Inside a Collection field
{
name: 'body',
type: 'richText',
editor: lexicalEditor({
features: ({ defaultFeatures }) => [
...defaultFeatures,
BlocksFeature({ blocks: [Hero, CallToAction] }),
LinkFeature({ fields: [{ name: 'utm', type: 'text' }] }),
],
}),
}
Spreading defaultFeatures and adding / replacing on top is the official recommended pattern.
Rendering on the frontend
Lexical output is stored as JSON, and the frontend renders it as React elements with Payload's official RichText component. The implementation is covered in chapter 06, section 6.10.
Localization
Config level
// payload.config.ts
export default buildConfig({
// ...
localization: {
locales: [
{ label: 'English', code: 'en' },
{ label: 'Arabic', code: 'ar', rtl: true },
{ label: '日本語', code: 'ja' },
],
defaultLocale: 'en',
fallback: true,
},
})
locales can also be a plain string array (['en', 'es', 'de']), and fallback: true (the default) returns the defaultLocale value when a requested locale has no value.
Field level
A field with localized: true is stored as a per-locale object in the DB.
{ name: 'title', type: 'text', localized: true }
The Admin UI grows a locale switcher, and editors enter values per locale.
Selecting a locale via the API
| API | How to write it |
|---|---|
| REST | GET /api/posts?locale=ja&fallback-locale=none |
| GraphQL | Posts(locale: ja, fallbackLocale: none) |
| Local | payload.find({ collection: 'posts', locale: 'ja', fallbackLocale: false }) |
Versions & Drafts
Enabling versions
Adding versions: true to a Collection turns on document history.
{
slug: 'posts',
versions: {
drafts: true,
maxPerDoc: 100, // default 100; older versions are pruned past this
},
fields: [/* ... */],
}
With drafts: true, a _status: 'draft' | 'published' field is added to each document, and drafts that are newer than the published version can coexist (Versions Overview). Access Control can be tuned so that, for example, "only the author and admins can read unpublished drafts."
Autosave
versions: {
drafts: {
autosave: true,
},
}
When autosave is on, the Admin UI automatically saves edits as new draft versions as you type. The existing published version is untouched until an editor explicitly clicks Publish.
Live Preview vs Draft Preview
- Live Preview: the frontend updates in real time inside the Admin UI as you type (iframe + postMessage)
- Draft Preview: an unpublished draft is shown on the frontend via a protected URL like
?preview=...
Live Preview is independent of Versions; Draft Preview requires drafts: true. Both are covered in 06 Next.js Integration.
Implementation example — narrowing Block choices with filterOptions
In a page builder you sometimes want "this Block is only available when condition X is true." Examples: "don't offer the Hero block while the doc is in drafts," "swap the available Block set when the editor picks the Article template."
The Blocks Field's filterOptions lets you filter the available Blocks based on other fields in the same document (Blocks Field doc).
{
name: 'layout',
type: 'blocks',
blocks: [Hero, RichText, CallToAction],
filterOptions: ({ siblingData }) => {
return siblingData?.enabledBlocks?.length
? [siblingData.enabledBlocks]
: true
},
}
- Returning
true→ allow every Block defined for the field - Returning an array of block slugs → restrict to those slugs
Use case: put a select field called enabledBlocks on the same Collection. The editor picks which Block set to use there, and the Blocks Field's Admin UI updates dynamically to match.
Source: Blocks Field (official docs)
Wrap-up
- Fields fall into three buckets: Data (DB-stored) / Presentational (UI-only) / Virtual (derived)
- Relational fields are
relationship(1:1 / 1:N / polymorphic) andupload(sugar over a Media reference) - The structural star is
blocks— the heart of a page builder, mixing different schemas in any order - Rich Text defaults to Lexical (stable); Slate is scheduled for removal in v4.0
- Localization is a two-layer setup —
localizationon the config andlocalized: trueon the field drafts: trueseparates draft and published;autosave: truesaves drafts continuously- The Blocks Field's
filterOptionsfilters available Blocks dynamically based on sibling field values
Next
In the next chapter, 04 Security & Lifecycle — Auth / Access Control / Hooks, we cover authentication, authorization, and the Hooks lifecycle — the layer that decides "who can read, who can write, and what fires the moment something changes" after the data is shaped.