WABBIT · ESSAYS

Adding a custom block to Payload's layout builder (Payload 3 + Next.js)

A custom block touches five places in a Payload 3 + Next.js project, and here's the order we wire them in, with the mistakes that cost us time.

Payload's docs explain the blocks field well. What they leave to you is the path from a block config to a section that renders on the page, has types, and updates in live preview. That path crosses five files, and missing any one of them fails quietly.

We'll build a small stat row: a headline plus a list of figures, each with a label and a value. It's a trimmed-down version of a block that runs on wabbit.com. The steps assume you started from Payload's official website template, so you already have a Pages collection with a layout field, a RenderBlocks component and live preview. Everything here is checked against Payload 3.90.

Write the block config

A block is a plain object typed as Block from payload. It needs a slug and fields. We always add interfaceName too, for reasons that show up in step 3.

TypeScript
// src/blocks/StatRow/config.ts
import type { Block } from 'payload'

export const StatRow: Block = {
  slug: 'statRow',
  interfaceName: 'StatRowBlock',
  labels: { singular: 'Stat Row', plural: 'Stat Rows' },
  fields: [
    { name: 'headline', type: 'text' },
    {
      name: 'stats',
      type: 'array',
      minRows: 2,
      maxRows: 6,
      fields: [
        { name: 'label', type: 'text', required: true },
        { name: 'value', type: 'text', required: true },
      ],
    },
  ],
}

The slug is what Payload stores on every instance of the block as blockType. It's also the key your render map will look up, so pick it once and don't rename it: existing documents keep the old value. Payload's own type comments warn that interfaceName values share one namespace with collections, arrays, groups and tabs, so keep them unique. We suffix ours with Block.

Register it in the blocks field

A block does nothing until a blocks field lists it. In the website template, that's the layout field inside the Pages collection.

TypeScript
// src/collections/Pages/index.ts
import { StatRow } from '../../blocks/StatRow/config'

// inside the collection's fields:
{
  name: 'layout',
  type: 'blocks',
  blocks: [
    // ...the template's existing blocks
    StatRow,
  ],
  required: true,
}

The order of that array is the order editors see in the block picker. On wabbit.com the list got long enough that we moved it into its own file, pageLayoutBlocks.ts, so our Posts collection can offer the same palette. If you plan to reuse blocks across collections, do that early. Restart the dev server and the block shows up under "Add Layout" in the admin.

Generate types

Payload writes TypeScript types from your config. Run it every time you add or change a field:

TerminalShell
npm run generate:types

The template wires that script to payload generate:types, which writes to the path in your config's typescript.outputFile (usually src/payload-types.ts). With interfaceName set, you get a named, exported interface:

TypeScript
// src/payload-types.ts (generated, trimmed)
export interface StatRowBlock {
  headline?: string | null;
  stats?:
    | {
        label: string;
        value: string;
        id?: string | null;
      }[]
    | null;
  id?: string | null;
  blockName?: string | null;
  blockType: 'statRow';
}

It's also a member of the Page['layout'] union. Without interfaceName, the block's shape only exists inline inside that union, and there's no StatRowBlock for your component to import. Notice the | null on every optional field. Your component has to handle null as well as undefined.

Build the component and add it to the render map

The component takes the block's fields as props. Import the generated type so a renamed field fails the type check instead of rendering blank.

TypeScript
// src/blocks/StatRow/Component.tsx
import React from 'react'
import type { StatRowBlock as StatRowProps } from '@/payload-types'

export const StatRowComponent: React.FC<StatRowProps> = ({ headline, stats }) => {
  const rows = (stats ?? []).filter(Boolean)
  return (
    <section className="container">
      {headline && <h2>{headline}</h2>}
      {rows.length > 0 && (
        <ul>
          {rows.map((s, i) => (
            <li key={s.id ?? i}>
              <p>{s.label}</p>
              <p>{s.value}</p>
            </li>
          ))}
        </ul>
      )}
    </section>
  )
}

There's one RenderBlocks component for the whole layout. It maps each blockType to a component and renders them in order. Add your block to its map, keyed by the slug:

TypeScript
// src/blocks/RenderBlocks.tsx (the relevant parts)
import React from 'react'
import type { Page } from '@/payload-types'
import { StatRowComponent } from '@/blocks/StatRow/Component'

const blockComponents: Record<string, React.ComponentType<any>> = {
  // ...the template's existing entries
  statRow: StatRowComponent,
}

export const RenderBlocks: React.FC<{ blocks: Page['layout'][0][] }> = ({ blocks }) => {
  if (!blocks?.length) return null
  return (
    <>
      {blocks.map((block, index) => {
        const Block = blockComponents[block.blockType]
        if (!Block) return null
        return <Block key={block.id ?? index} {...block} />
      })}
    </>
  )
}

Your page route already calls <RenderBlocks blocks={page.layout} />, so nothing changes there. (Our version on wabbit.com adds shared spacing and width controls on top of this map; you don't need any of that to start.)

Check live preview and draft mode

If the template's preview wiring is intact, a new block needs no extra preview code. It still helps to know the four pieces, because when preview breaks it's almost always one of them.

The collection tells the admin where to preview, and saves drafts often enough for preview to feel live:

TypeScript
// src/collections/Pages/index.ts
admin: {
  livePreview: {
    url: ({ data, req }) =>
      generatePreviewPath({ slug: data?.slug, collection: 'pages', req }),
  },
},
versions: {
  drafts: {
    autosave: { interval: 1000 },
  },
},

generatePreviewPath points at a route handler, /next/preview, that checks PREVIEW_SECRET, confirms there's a logged-in user with payload.auth, calls (await draftMode()).enable() and redirects to the page.

The page then reads drafts when draft mode is on:

TypeScript
// src/app/(frontend)/[slug]/page.tsx (query)
const { isEnabled: draft } = await draftMode()
const result = await payload.find({
  collection: 'pages',
  draft,
  overrideAccess: draft,
  limit: 1,
  pagination: false,
  where: { slug: { equals: slug } },
})

And it mounts a small client component, only in draft mode, that refreshes the route each time the admin autosaves:

TypeScript
// src/components/LivePreviewListener/index.tsx
'use client'
import { RefreshRouteOnSave } from '@payloadcms/live-preview-react'
import { useRouter } from 'next/navigation'
import React from 'react'
import { getClientSideURL } from '@/utilities/getURL'

export const LivePreviewListener: React.FC = () => {
  const router = useRouter()
  return <RefreshRouteOnSave refresh={router.refresh} serverURL={getClientSideURL()} />
}
TypeScript
// in the page component
{draft && <LivePreviewListener />}

Because RenderBlocks is a server component, router.refresh() re-runs the draft query and re-renders every block, your new one included. Add a stat row in the admin, type a value, and it appears in the preview pane about a second later.

Common mistakes

Forgetting to regenerate types. You add a field to the config, the admin shows it, editors fill it in, and the component never reads it because StatRowBlock in payload-types.ts doesn't have it yet. Worse, a component with hand-written props will compile against a shape Payload no longer stores. Run npm run generate:types after every config change and commit the result with it. We've found comments in our own components that say "not yet in generated payload-types". Each one is a field somebody added without regenerating.

Select values GraphQL can't name. Payload builds a GraphQL enum from every select field's option values. In 3.90, the function that cleans those values up (formatName in @payloadcms/graphql) replaces dots, hyphens, slashes, plus signs, commas, parentheses, apostrophes and brackets, and strips spaces, but it doesn't touch colons. So a select option with the value 16:9 becomes the enum key _16:9, and GraphQL throws while building the schema. Every request to /api/graphql then fails. Whether it breaks depends on where the field sits: the where-input builder that hits this only walks collection-level and global-level fields, so 16:9 inside a block is harmless. The same field added directly to a collection takes the whole GraphQL API down. We hit it with an image-display field on one of our collections. If your project uses GraphQL, give those options values like 16/9, or disable GraphQL with graphQL: { disable: true } in your Payload config if nothing reads it. We did the latter, since nothing on the site or in the admin used it.

A block with no render-map entry renders nothing. The map lookup in step 4 returns null for an unknown blockType. There's no error and no placeholder. The block saves fine in the admin and leaves a gap on the page. When a new block "doesn't show up", check the map first.

A map key that doesn't match the slug. The key is the block's slug (statRow). It isn't the interfaceName (StatRowBlock) or the folder name. A key of StatRow misses for the same silent reason as a missing entry.

Or skip the build

If you'd rather start from blocks that already work, Tome's block packs install into an existing Payload 3 + Next.js app, the one you already have or a fresh one from Payload's website template. Each pack gives you Payload block configs to add to your Pages blocks field, next to whatever's already there. Browse the hero blocks, then follow Get started.

More from the Library: Why every Payload page ships every block's CSS, and how we cut ours from 444 KB to 112 KB · Payload CMS SEO in Next.js: the static sitemap that hid 55 pages from Google