
Quick answer: The Payload CMS blocks field is an ordered array whose rows can have different schemas, which is what makes a page builder possible: one layout field on a Pages collection where the first row is a hero, the second a text-and-image split, and the third a pricing table. You define each row type as a block — { slug, fields, labels } — and Payload stores instances with a blockType discriminator, so your React component switches on blockType and renders the matching component. Blocks you reuse across several collections can be defined once at the top level of the config and referenced by slug.
Part 11 of our Payload CMS series. If collections are still new, read install Payload 3 and build your first collection, and read rendering Payload rich text in React first — the blocks field is the page-level sibling of that renderer.
Table of Contents
1. What a blocks field actually is
Payload has three ways to store repeating data, and picking the wrong one is the mistake that forces a rewrite later:
array— repeated rows that all share one schema. Slides, agenda items, FAQ entries.blocks— repeated rows where each row can have a different schema. Page sections, landing-page layouts.group— fields nested under one property, or visually grouped in the admin, without repeating anything.
The blocks field is the heterogeneous one. A layout field accepts any block the field declares, in any order, as many times as minRows and maxRows allow. That single property is what turns a CMS from “a post body” into “a page composer”.
// collections/Pages.ts
import type { CollectionConfig } from 'payload'
export const Pages: CollectionConfig = {
slug: 'pages',
admin: { useAsTitle: 'title' },
fields: [
{ name: 'title', type: 'text', required: true },
{ name: 'slug', type: 'text', unique: true, index: true },
{
name: 'layout',
type: 'blocks',
minRows: 1,
blocks: [Hero, TextWithImage, PricingTable],
},
],
}The important thing to notice is that layout is a field like any other. It has access rules, hooks, a localized flag and generated types — it is not a special mode of the admin panel. It is also the field most likely to make you want drafts and versions, because a half-built layout is the classic reason to save without publishing.
2. Defining a block
A block definition is a small object. The two required keys are slug and fields:
// blocks/Hero.ts
import type { Block } from 'payload'
export const Hero: Block = {
slug: 'hero',
labels: { singular: 'Hero', plural: 'Heroes' },
interfaceName: 'HeroBlock',
admin: {
group: 'Marketing',
images: { thumbnail: { url: '/block-previews/hero.png', alt: 'Hero preview' } },
},
fields: [
{ name: 'heading', type: 'text', required: true },
{ name: 'subheading', type: 'textarea' },
{
name: 'background',
type: 'upload',
relationTo: 'media',
admin: { description: 'Wide image, at least 1600px.' },
},
],
}| Block option | What it does |
|---|---|
slug | Required. The block’s identifier, and the value stored in each instance’s blockType. |
fields | Required. The fields this block contributes to the stored row. |
labels | Admin-facing singular and plural names; auto-generated from the slug if omitted. |
interfaceName | Names a reusable TypeScript interface and GraphQL type for this block. |
dbName | Custom table name for the SQL adapter. Rarely needed. |
admin.group | Groups the block inside the admin’s block drawer, so twenty block types stay findable. |
admin.images | Drawer icon and thumbnail previews, each a URL or { url, alt }. |
admin.disableBlockName | Hides the per-instance name field when you do not want editors labelling rows. |
admin.components | Custom Block and Label components for a bespoke admin row. |
custom | Extension point for your own metadata. |
That background field in the example is an upload relationship, so the file itself lives in a media collection with its own image sizes, storage adapter and access rules.
Two notes worth having in front of you while you write these:
- Preview images moved. Older examples show top-level
imageURLandimageAltTexton a block definition, and the current documentation describesadmin.images.iconandadmin.images.thumbnailinstead. Write theadmin.imagesform, and if a legacy key appears in a snippet you copied, check it against the installedBlocktype rather than trusting the example. - There is no block-level
descriptionkey. Descriptions belong on the fields inside the block (admin.description), or on a custom label component.
3. Field-level options that matter
The blocks field has its own options on top of the block definitions:
| Field option | What it does |
|---|---|
blocks | Required. The block types this field accepts. |
minRows, maxRows | Bound how many rows a document may contain, validated when a value is present. |
labels | Customises how block rows are labelled in the admin. |
localized | Localises the field and everything nested inside it. |
admin.initCollapsed | Rows start collapsed, which keeps a long layout readable. |
admin.isSortable | Whether editors can reorder rows. Layout order is usually meaningful, so this is the point of the field. |
defaultValue, validate, hooks, access, hidden | The usual field capabilities, applied to the whole layout. |
typescriptSchema, virtual, custom | Escape hatches for generated types, computed fields and metadata. |
One footgun: unique: true on a field inside a block creates a collection-wide database index, not uniqueness within a single page’s rows. If you want “no duplicate section anchors on this page”, that is custom validation against the current document’s rows, not the unique flag.
4. Blocks, arrays, groups and rich-text blocks
Teams ask this every time, so here is the decision in one table:
| Use | When |
|---|---|
array | Every row has the same fields. FAQ lists, team members, slides. |
blocks | Rows are interchangeable content types with their own schemas. Page layouts, section builders. |
group | One nested object. SEO metadata, coordinates, a call-to-action pair. |
| Rich-text blocks | Block nodes inside a rich-text field, mixed inline with paragraphs and lists. |
The last row is the one that gets confused with the blocks field, and the distinction is about flow rather than flexibility. A rich-text block lives within prose — a callout between two paragraphs, a pull quote mid-article. A blocks field places top-level sections that a layout renders one after another. Payload supports both; they answer different questions, and picking wrong shows up as “the editor keeps inserting a section inside a paragraph”.
5. Reusable blocks with blockReferences
The default is to declare blocks inline on the field, which is fine until the same hero appears on Pages, Landing Pages and Posts. Payload’s answer is to define blocks once at the top level of the config and reference them by slug:
// payload.config.ts — excerpt
export default buildConfig({
blocks: [TextBlock, CallToActionBlock],
collections: [
{
slug: 'pages',
fields: [
{
name: 'layout',
type: 'blocks',
blockReferences: ['TextBlock', 'CallToActionBlock'],
blocks: [], // documented as required for compatibility
},
],
},
],
})What to expect from references:
- The content shape does not change. Referenced blocks store the same
blockTypeplus fields. References change how the configuration is resolved, not the data. - Referenced blocks are isolated. A collection cannot extend or modify them, and their access control runs without collection data, so do not design a referenced block that expects to read its parent document.
- They pay off at scale. Payload cites reduced field traversal and a smaller admin-client payload as the reason references exist. On a project with a handful of blocks, inline definitions are simpler.
6. What actually gets stored
A blocks field stores an ordered array of objects. Each one carries the fields of its block, plus:
blockType— the block’s slug. This is the reliable discriminator.blockName— an optional per-instance label an editor can set, so three “Text” rows can be named “Intro”, “Terms” and “Footer note”. Hide it withadmin.disableBlockNamewhen it is just noise.id— a generated row identifier, and the natural React key.
A caveat about that id from the generated types: it is often typed as nullable (id?: string | null), so do not write a renderer that assumes it is always a present, non-null string. Use it when it exists, and have a deterministic fallback — the array index, or a stable combination of the block’s own values. A random key generated at render time is not a fallback; it defeats reconciliation on every render.
The generated TypeScript is the real gift here. With interfaceName set on your blocks and types generated, a layout field comes back as a union array, so a switch on blockType gets exhaustiveness checking and a new block type shows up as a type error in every renderer that has not handled it.
7. Rendering a layout in React
Rendering is a switch. Fetch the page with the Local API in a server component, then map the layout:
// components/PageLayout.tsx — server component
import type { Page } from '@/payload-types'
import { Hero } from './blocks/Hero'
import { TextWithImage } from './blocks/TextWithImage'
import { PricingTable } from './blocks/PricingTable'
type LayoutBlock = NonNullable<Page['layout']>[number]
export function PageLayout({ blocks }: { blocks: LayoutBlock[] }) {
return (
<>
{blocks.map((block, i) => {
const key = block.id ?? `${block.blockType}-${i}`
switch (block.blockType) {
case 'hero':
return <Hero key={key} block={block} />
case 'textWithImage':
return <TextWithImage key={key} block={block} />
case 'pricingTable':
return <PricingTable key={key} block={block} />
default:
return null
}
})}
</>
)
}Three habits keep this maintainable:
- Keep each block component typed to its own block. The union narrows inside the
case, soHeroshould accept the hero type, not the whole union. - Do not write a
defaultthat renders something generic. Returningnullfor an unknown block type is honest; rendering a stub makes a missing handler look like a content problem. - Fetch what the blocks need up front. Blocks that hold relationships — an author, a product, a related-post list — should be populated by the page query’s
depth, not by per-block queries during render.
8. Nesting, depth and the recursive trap
Blocks can contain blocks and arrays, and a column-with-children block is a legitimate design. Two limits are worth respecting:
maxDepthis about populating relationships, not about block recursion. Raising it does not make a self-referential block schema safe; it only affects how deep related documents get expanded. Query depth defaults to a shallow value, so a block holding a relationship needs that depth to render anything beyond an ID.- Recursive block definitions are a known pain point. A block whose fields include the field that contains it can send config traversal into a loop, and Payload’s own guidance and community discussions treat it as a problem rather than a supported pattern. Build a fixed nesting depth — section, then columns, then card — and stop.
The admin side of the same concern is performance: a layout with deep nesting and many block types sends a bigger config to the admin client, which is exactly the cost blockReferences exists to reduce.
9. Localisation and the admin experience
Setting localized: true on the blocks field localises the entire layout and everything inside it — every block, in every language, edited per locale. The alternative is localising individual fields inside individual blocks, which keeps shared structure (images, URLs, prices) in sync across languages while only the text varies. Both are valid; the important part is that changing a field to or from localised changes its stored data structure, so plan a migration for existing content rather than flipping the flag on a live collection.
The admin affordances that make a builder usable are mostly declarative: admin.group to organise the drawer, admin.images for previews, admin.initCollapsed to keep long layouts readable, sorting for order, and custom Block and Label components when a row needs to show a summary of its own content. Editors can also copy and paste block rows; when pasting, Payload regenerates the block and nested-array IDs, which is why duplicated rows do not share an identity.
10. The errors you will actually hit
| Symptom | Cause and fix |
|---|---|
| Editor cannot insert a section that should exist | The block is not listed in the field’s blocks, or a blockReferences slug does not match a top-level block slug |
| Renderer drops content silently | The blockType case is missing. Keep the default returning nothing visible and let testing catch it |
| React warns about duplicate keys or reorders content | Keying on the array index while id is available, or using a random key. Prefer id with a deterministic fallback |
blockReferences field fails to build | The field still needs a blocks key alongside it; the docs show an empty array as required for compatibility |
| Related documents inside a block arrive as IDs | Query depth is too low. Raise depth on the page query instead of fetching per block |
| Config traversal or admin panel slows to a crawl | Deep nesting with many inline block types. Flatten the schema or move shared blocks to blockReferences |
| Unique field inside a block behaves oddly | unique: true creates a collection-wide index. Use custom validation for per-document uniqueness |
| Changing a field’s locale support breaks existing pages | Localising a field changes its stored shape. Plan the migration before flipping localized on a populated collection |
11. Key takeaways and challenge
blocksis the heterogeneous array: one field, many row schemas.arrayis for repeated rows that share a schema;groupis for non-repeating nesting.- A block is
{ slug, fields, labels }, plusinterfaceNameif you want a named generated type, andadmin.groupandadmin.imagesto keep the drawer usable. - Use
admin.imagesfor previews; the older top-levelimageURLandimageAltTextkeys are legacy and worth verifying against your installed types. - Instances store
blockType(the discriminator), an optional per-instanceblockName, and anidthat generated types may mark nullable — so key carefully. - Rendering is a switch on
blockTypeover a generated union array. Keep each block component typed to its own block and let thedefaultcase return nothing. - Reusable blocks go in the top-level config and are referenced by slug; the field keeps an empty
blockskey alongsideblockReferences. - Build finite nesting. Recursive block schemas are a known trap, and
maxDepthgoverns relationship population, not block recursion. - Localising a blocks field localises the whole layout; localising selected fields keeps shared structure in sync. Either way, changing it is a migration.
Challenge: build a Pages collection whose layout field accepts four blocks — hero, text-with-image, pricing table and CTA — and render it as a server component typed against the generated union, so that adding a fifth block turns the renderer into a compile error until it is handled. Then move two of those blocks into a top-level config and reference them with blockReferences from a second collection, and note what changed in the stored data (it should be nothing) versus what changed in the admin drawer. Finally, write down the depth you set on the page query and one block field that would return an ID instead of a document if you lowered it.
Building a page builder on Payload and want a second pair of eyes on the block model before content lands? Ampersand Academy runs one-to-one mentoring on TypeScript and Next.js backends. Built and run by Mahadhi — development and digital marketing.
What is the difference between an array field and a blocks field in Payload?
An array field repeats rows that all share one schema, such as slides or agenda items. A blocks field repeats rows that can each have a different schema, which is what lets one layout field hold a hero, a text section and a pricing table in any order.
How do I render a blocks field in React?
Fetch the document with the Local API, then map the array and switch on each row’s blockType to choose a component. Generated TypeScript describes the field as a union array, so a new block type becomes a type error in every renderer that has not handled it.
What is blockReferences and when should I use it?
It lets you define blocks once at the top level of the Payload config and reference them by slug from any collection or global. Use it when the same blocks appear in several places, because it avoids repeated config traversal and keeps the admin client payload smaller.
Why does my stored data contain blockType?
It is the discriminator: Payload stores each row with the slug of the block it was created from. Your renderer switches on that value, which is more reliable than inferring the block from whichever fields happen to be present.
Can blocks contain other blocks?
Yes, and a column layout is a normal use for that. Keep the nesting finite, because a block that contains the field containing it can loop config traversal. Query depth is a separate setting that controls how far related documents are populated.
What does blockName do?
It is an optional label an editor can set on a single row, so several rows of the same type can be told apart in the admin. You can hide the control with the block’s admin.disableBlockName option when a label adds nothing.
How do I localise a page layout?
Set localized true on the blocks field to localise the whole layout and everything inside it, or localise individual fields inside blocks to keep shared values such as images in sync across locales. Changing a field’s localisation changes its stored shape, so plan a migration first.
Last updated on · Written by Dinesh Kumar R
