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.

Payload CMS blocks field powering a page layout builder

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 optionWhat it does
slugRequired. The block’s identifier, and the value stored in each instance’s blockType.
fieldsRequired. The fields this block contributes to the stored row.
labelsAdmin-facing singular and plural names; auto-generated from the slug if omitted.
interfaceNameNames a reusable TypeScript interface and GraphQL type for this block.
dbNameCustom table name for the SQL adapter. Rarely needed.
admin.groupGroups the block inside the admin’s block drawer, so twenty block types stay findable.
admin.imagesDrawer icon and thumbnail previews, each a URL or { url, alt }.
admin.disableBlockNameHides the per-instance name field when you do not want editors labelling rows.
admin.componentsCustom Block and Label components for a bespoke admin row.
customExtension 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 imageURL and imageAltText on a block definition, and the current documentation describes admin.images.icon and admin.images.thumbnail instead. Write the admin.images form, and if a legacy key appears in a snippet you copied, check it against the installed Block type rather than trusting the example.
  • There is no block-level description key. 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 optionWhat it does
blocksRequired. The block types this field accepts.
minRows, maxRowsBound how many rows a document may contain, validated when a value is present.
labelsCustomises how block rows are labelled in the admin.
localizedLocalises the field and everything nested inside it.
admin.initCollapsedRows start collapsed, which keeps a long layout readable.
admin.isSortableWhether editors can reorder rows. Layout order is usually meaningful, so this is the point of the field.
defaultValue, validate, hooks, access, hiddenThe usual field capabilities, applied to the whole layout.
typescriptSchema, virtual, customEscape 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:

UseWhen
arrayEvery row has the same fields. FAQ lists, team members, slides.
blocksRows are interchangeable content types with their own schemas. Page layouts, section builders.
groupOne nested object. SEO metadata, coordinates, a call-to-action pair.
Rich-text blocksBlock 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 blockType plus 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 with admin.disableBlockName when 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, so Hero should accept the hero type, not the whole union.
  • Do not write a default that renders something generic. Returning null for 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:

  • maxDepth is 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

SymptomCause and fix
Editor cannot insert a section that should existThe block is not listed in the field’s blocks, or a blockReferences slug does not match a top-level block slug
Renderer drops content silentlyThe blockType case is missing. Keep the default returning nothing visible and let testing catch it
React warns about duplicate keys or reorders contentKeying on the array index while id is available, or using a random key. Prefer id with a deterministic fallback
blockReferences field fails to buildThe 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 IDsQuery depth is too low. Raise depth on the page query instead of fetching per block
Config traversal or admin panel slows to a crawlDeep nesting with many inline block types. Flatten the schema or move shared blocks to blockReferences
Unique field inside a block behaves oddlyunique: true creates a collection-wide index. Use custom validation for per-document uniqueness
Changing a field’s locale support breaks existing pagesLocalising a field changes its stored shape. Plan the migration before flipping localized on a populated collection

11. Key takeaways and challenge

  • blocks is the heterogeneous array: one field, many row schemas. array is for repeated rows that share a schema; group is for non-repeating nesting.
  • A block is { slug, fields, labels }, plus interfaceName if you want a named generated type, and admin.group and admin.images to keep the drawer usable.
  • Use admin.images for previews; the older top-level imageURL and imageAltText keys are legacy and worth verifying against your installed types.
  • Instances store blockType (the discriminator), an optional per-instance blockName, and an id that generated types may mark nullable — so key carefully.
  • Rendering is a switch on blockType over a generated union array. Keep each block component typed to its own block and let the default case return nothing.
  • Reusable blocks go in the top-level config and are referenced by slug; the field keeps an empty blocks key alongside blockReferences.
  • Build finite nesting. Recursive block schemas are a known trap, and maxDepth governs 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