Web DevelopmentPayload CMSLexicalRich TextCustom Blocks

Lexical Rich Text: Building Custom Blocks in Payload CMS

Adding Callout, CodeBlock, and Banner blocks to the Lexical editor in Payload CMS 3 — with full TypeScript types.

March 31, 2026

lexical custom blocks payload cms

Payload CMS 3's Lexical editor is more than a rich text box — it's a block editor. You can define custom blocks that editors insert inline into content, with their own fields, validation, and admin UI. This tutorial builds three blocks used in this blog: Callout, CodeBlock, and Banner.

How Blocks Work in Lexical

A Lexical block is a Payload Block config paired with a React component for rendering. Payload renders the block fields as a form in the editor. The values are stored as JSON in the Lexical document tree. On the front-end, you parse the Lexical JSON and render each block type with its corresponding React component.

Defining the Blocks

Create apps/cms/src/blocks/index.ts:

import type { Block } from 'payload'

export const Callout: Block = {
  slug: 'callout',
  labels: { singular: 'Callout', plural: 'Callouts' },
  fields: [
    {
      name: 'type',
      type: 'select',
      required: true,
      defaultValue: 'info',
      options: [
        { label: 'Info', value: 'info' },
        { label: 'Warning', value: 'warning' },
        { label: 'Success', value: 'success' },
        { label: 'Error', value: 'error' },
      ],
    },
    {
      name: 'message',
      type: 'textarea',
      required: true,
    },
  ],
}

export const CodeBlock: Block = {
  slug: 'codeBlock',
  labels: { singular: 'Code Block', plural: 'Code Blocks' },
  fields: [
    {
      name: 'language',
      type: 'select',
      required: true,
      defaultValue: 'typescript',
      options: [
        { label: 'TypeScript', value: 'typescript' },
        { label: 'JavaScript', value: 'javascript' },
        { label: 'TSX', value: 'tsx' },
        { label: 'Bash', value: 'bash' },
        { label: 'SQL', value: 'sql' },
        { label: 'JSON', value: 'json' },
        { label: 'YAML', value: 'yaml' },
        { label: 'Python', value: 'python' },
        { label: 'Dockerfile', value: 'dockerfile' },
        { label: 'CSS', value: 'css' },
      ],
    },
    {
      name: 'code',
      type: 'code',
      required: true,
    },
  ],
}

export const Banner: Block = {
  slug: 'banner',
  labels: { singular: 'Banner', plural: 'Banners' },
  fields: [
    {
      name: 'type',
      type: 'select',
      required: true,
      defaultValue: 'info',
      options: [
        { label: 'Info', value: 'info' },
        { label: 'Alert', value: 'alert' },
        { label: 'Promo', value: 'promo' },
      ],
    },
    {
      name: 'message',
      type: 'text',
      required: true,
    },
    {
      name: 'link',
      type: 'group',
      fields: [
        { name: 'label', type: 'text' },
        { name: 'href', type: 'text' },
      ],
    },
  ],
}

Registering Blocks in the Editor

Add them to the BlocksFeature in your Posts collection:

import { BlocksFeature, lexicalEditor } from '@payloadcms/richtext-lexical'
import { Callout, CodeBlock, Banner } from '../blocks'

// Inside Posts collection config:
{
  name: 'content',
  type: 'richText',
  editor: lexicalEditor({
    features: ({ rootFeatures }) => [
      ...rootFeatures,
      BlocksFeature({
        blocks: [Callout, CodeBlock, Banner],
      }),
    ],
  }),
}

After adding new blocks, regenerate the import map:

cd apps/cms
pnpm payload generate:importmap

This is easy to forget. If blocks don't appear in the editor, this is the first thing to check.

Rendering Blocks on the Front-End

The Lexical JSON contains nodes. Block nodes have a fields property with the block data. A recursive renderer processes the tree:

// apps/web/src/components/LexicalRenderer.tsx
import type { SerializedEditorState, SerializedLexicalNode } from '@payloadcms/richtext-lexical/lexical'

function renderNode(node: SerializedLexicalNode): React.ReactNode {
  switch (node.type) {
    case 'paragraph':
      return <p>{renderChildren(node)}</p>

    case 'heading':
      const Tag = (node as any).tag as 'h1' | 'h2' | 'h3' | 'h4'
      return <Tag>{renderChildren(node)}</Tag>

    case 'text':
      return (node as any).text

    case 'block':
      return renderBlock((node as any).fields)

    default:
      return null
  }
}

function renderBlock(fields: { blockType: string; [key: string]: unknown }) {
  switch (fields.blockType) {
    case 'callout':
      return <CalloutBlock type={fields.type as string} message={fields.message as string} />

    case 'codeBlock':
      return <CodeBlockRenderer language={fields.language as string} code={fields.code as string} />

    case 'banner':
      return <BannerBlock {...(fields as any)} />

    default:
      return null
  }
}

The React Block Components

// Callout
const variants = {
  info: 'bg-blue-50 border-blue-200 text-blue-800',
  warning: 'bg-yellow-50 border-yellow-200 text-yellow-800',
  success: 'bg-green-50 border-green-200 text-green-800',
  error: 'bg-red-50 border-red-200 text-red-800',
}

export function CalloutBlock({ type, message }: { type: string; message: string }) {
  return (
    <div className={`border-l-4 p-4 my-4 rounded-r-lg ${variants[type as keyof typeof variants]}`}>
      <p className="font-medium">{message}</p>
    </div>
  )
}

// Banner
export function BannerBlock({
  type,
  message,
  link,
}: {
  type: string
  message: string
  link?: { label: string; href: string }
}) {
  const bg = type === 'promo' ? 'bg-accent/10' : type === 'alert' ? 'bg-red-50' : 'bg-blue-50'
  return (
    <div className={`${bg} p-4 rounded-lg my-4 flex items-center justify-between`}>
      <p>{message}</p>
      {link?.href && (
        <a href={link.href} className="font-medium underline ml-4 shrink-0">
          {link.label}
        </a>
      )}
    </div>
  )
}

Regenerating Types After Schema Changes

After modifying any block definition, regenerate Payload's TypeScript types:

cd apps/cms
pnpm generate:types

This updates src/payload-types.ts with the new field shapes. Import these types in your rendering code for full type safety on fields.

Key Takeaways

  • Blocks are Block configs with fields — Payload generates the admin UI automatically
  • Register blocks via BlocksFeature in the editor config; run generate:importmap after changes
  • Block data lives in Lexical JSON as block nodes with a fields object
  • A recursive renderer walks the node tree and delegates block rendering to React components
  • Always run generate:types after changing block fields to keep TypeScript types in sync