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

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.
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.
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' },
],
},
],
}
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.
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
}
}
// 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>
)
}
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.
Block configs with fields — Payload generates the admin UI automaticallyBlocksFeature in the editor config; run generate:importmap after changesblock nodes with a fields objectgenerate:types after changing block fields to keep TypeScript types in sync