Docs

This page isn't translated yet

Converters

Converter signature and patterns for rich text, media, page references, arrays, theme inheritance and inline blocks.

On this page

A converter turns a block's serialized Lexical node into JSX on the frontend. It receives the node and a nodesToJSX helper for nested editor content.

Converter signatureLink to this section

type CustomBlockConverter<T = any> = (props: {
  node: SerializedBlockNode<T>
  nodesToJSX: (args: { nodes: unknown[] }) => JSX.Element[]
}) => JSX.Element | null

With rich text (nodesToJSX)Link to this section

import type { SerializedEditorState } from '@payloadcms/richtext-lexical/lexical'

export const sectionConverter: CustomBlockConverter = ({ node, nodesToJSX }) => {
  const { theme, content, padding } = node.fields as {
    theme?: string
    content?: SerializedEditorState
    padding?: number
  }
  const contentJSX = content?.root?.children
    ? nodesToJSX({ nodes: content.root.children })
    : null
  return (
    <Section theme={theme} padding={padding}>
      {contentJSX}
    </Section>
  )
}

With media (uploads)Link to this section

Uploads come back as either the full document or just the ID, depending on depth — always check.

import type { Upload } from '@/payload-types'

const image = typeof fields.image === 'object' ? fields.image : null
if (!image?.url) return null
;<Image
  src={image.url}
  alt={image.alt || ''}
  width={image.width || 0}
  height={image.height || 0}
/>

With page referencesLink to this section

import type { Page } from '@/payload-types'

const { linkType, url, reference, newTab } = node.fields as {
  linkType?: 'custom' | 'internal'
  url?: string
  reference?: Page | number
  newTab?: boolean
}

const href =
  linkType === 'custom'
    ? url || ''
    : reference && typeof reference === 'object' && 'fullPath' in reference
      ? reference.fullPath || '/'
      : ''

With array fieldsLink to this section

const { items } = node.fields as { items?: (Upload | number)[] }
items?.map((item, i) => {
  if (typeof item !== 'object') return null
  return (
    <CarouselItem key={i}>
      <Image src={item.url || ''} alt={item.alt || ''} />
    </CarouselItem>
  )
})

Theme inheritanceLink to this section

Theme 'inherit' means use the parent's theme — pass undefined to skip applying a theme on this block.

<Section
  theme={theme && theme !== 'inherit' ? theme : undefined}
  layoutBackground={
    theme && theme !== 'inherit' ? layoutBg : undefined
  }
/>

Block name → ID/ARIALink to this section

import { toUrlCase } from '@systhemaui/core'

// Non-interactive block wrappers expose `blockName` as a stable anchor id
// (and, for landmark containers, as an accessible label).
;<section
  id={blockName ? toUrlCase(blockName) : undefined}
  aria-label={blockName || undefined}
/>

Inline blocks (button, icon)Link to this section

Both the built-in button and icon inline blocks share the same link-fields shape (linkType, url, reference, newTab). Render with the polymorphic *.a variant when a link resolves, plain when it doesn't:

import type { ButtonVariant } from '@systhemaui/core'
import { Button, ButtonIcon, ButtonTitle } from '@systhemaui/next'
import { resolveIconForRender } from '@systhemaui/payload/fields/iconPicker/resolveIconForRender'

export const buttonConverter: CustomBlockConverter = ({ node }) => {
  const { variant, title, iconBefore, iconAfter, linkType, url, reference, newTab } =
    node.fields as {
      variant?: ButtonVariant
      title?: string
      iconBefore?: string
      iconAfter?: string
      linkType?: 'custom' | 'internal'
      url?: string
      reference?: Page | number
      newTab?: boolean
    }
  const href = linkType === 'custom' ? url || '' : /* resolve reference */ ''
  return (
    <Button.a href={href} variant={variant} target={newTab ? '_blank' : undefined}>
      {iconBefore && (
        <ButtonIcon dangerouslySetInnerHTML={{ __html: resolveIconForRender(iconBefore).svg }} />
      )}
      {title && <ButtonTitle>{title}</ButtonTitle>}
      {iconAfter && (
        <ButtonIcon dangerouslySetInnerHTML={{ __html: resolveIconForRender(iconAfter).svg }} />
      )}
    </Button.a>
  )
}

The icon block ships its own converter — when a link resolves the converter renders <Icon.a> (Next-aware Link via @systhemaui/next); otherwise it renders plain <Icon> so the hover affordance only kicks in for actual links. The same shape covers the linkable Image / Video media blocks. The icon block stores its link fields in a collapsed-by-default "Link Settings" section in the Edit Icon modal, including an Accessible label field that becomes the linked icon's aria-label (a text-less linked icon otherwise has no accessible name); when empty, a name is derived from the link's domain. For the four-state resolver pattern that custom inline-block converters should mirror, see Linkable icons → custom inline-block converters.