🚧 Under development — suggestions for new items are always welcome! ✨

Rich Text

Turn a Payload rich‑text field into clean HTML, ready‑styled with Tailwind, and still fully overrideable.

Demo Here

What it does

  • Renders Lexical JSON straight into React/HTML.
  • Tailwind helpers for alignment, lists, indentation and table cells.
  • classNames map – add utilities per tag (h1pul…).
  • Component overrides – drop in your own React component for any tag.
  • Nested‑list fix – child lists sit under the previous <li> instead of an empty one.

How it works (high‑level)

  • processNodes(value) normalises the raw JSON (mainly lists).
  • getNodeAttributes(node) returns the Tailwind classes/attrs for each element.
  • createConverters() marries those attrs with your components and hands the map to the underlying @payloadcms/richtext-lexical/react renderer.

Install

pnpm dlx [email protected] add https://p.livog.com/r/rich-text.json

Code

import type { SerializedEditorState } from '@payloadcms/richtext-lexical/lexical'
import type { ComponentsMap, AllowedClassNames, CreateConvertersProps } from './types'
import { RichText as PayloadRichText } from '@payloadcms/richtext-lexical/react'
import { cn } from '@/utils/cn'
import processNodes from './process-nodes'
import { createConverters, defaultComponents } from './use-rich-text'

export { createConverters, defaultComponents, type ComponentsMap, type AllowedClassNames, type CreateConvertersProps }

export type RichTextProps = {
  data: SerializedEditorState
  components?: Partial<ComponentsMap>
  className?: string
  classNames?: AllowedClassNames
  columnSize?: number
  blockIndex?: number
  lazyLoadImages?: boolean
  blocks?: CreateConvertersProps['blocks']
  inlineBlocks?: CreateConvertersProps['inlineBlocks']
}

export function RichText({ data, components: userComponents, className, classNames, blocks, inlineBlocks, ...props }: RichTextProps) {
  const components = { ...defaultComponents, ...userComponents }
  const converters = createConverters(components, {
    ...props,
    classNames,
    blocks: blocks ?? {},
    inlineBlocks
  })
  data = processNodes(data)

  return (
    <PayloadRichText
      data={data}
      converters={converters}
      className={cn('whitespace-pre-wrap', className)}
      disableIndent
      disableTextAlign
      disableContainer
    />
  )
}

export default RichText

This component includes both a CMS Link component and a link field:

  • The link field relies on shadcn/ui.
  • The CMS Link component requires importing the type ButtonSize from @/components/ui/button/config.
    The config file exports the variants and size objects intended for use with cva. These exports occur before their usage in cva, enabling us to leverage them effectively as type definitions.

Looks like this:

// components/ui/button/config.ts
import { cva, type VariantProps } from 'class-variance-authority'
import type { ComponentPropsWithRef } from 'react'

export const buttonConfig = {
  variants: {
    default: 'bg-primary text-primary-foreground hover:bg-primary/90',
    destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
    outline: 'border-primary/20 bg-background text-foreground hover:bg-accent hover:text-accent-foreground border',
    secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
    ghost: 'hover:bg-accent hover:text-accent-foreground',
    link: 'text-primary inline underline-offset-4 hover:underline'
  },
  sizes: {
    default: 'h-10 px-4 py-2',
    sm: 'h-9 rounded-md px-3',
    lg: 'h-11 rounded-md px-8',
    icon: 'h-10 w-10'
  }
} as const

export const buttonVariants = cva(
  `ring-offset-background focus-visible:ring-ring inline-flex cursor-pointer items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0`,
  {
    variants: {
      variant: buttonConfig.variants,
      size: buttonConfig.sizes
    },
    defaultVariants: {
      variant: 'default',
      size: 'default'
    }
  }
)

export type ButtonVariant = keyof typeof buttonConfig.variants
export type ButtonSize = keyof typeof buttonConfig.sizes

export type ButtonProps = ComponentPropsWithRef<'button'> &
  VariantProps<typeof buttonVariants> & {
    asChild?: boolean
  }

Examples

Basic usage

import { RichText } from '@/components/rich-text'

export function Article({ content }) {
  return <RichText data={content} />
}

Add Tailwind utilities

<RichText
  data={content}
  classNames={{
    h1: 'scroll-m-20 text-4xl font-extrabold tracking-tight',
    p:  'leading-7 [&:not(:first-child)]:mt-6'
  }}
/>

Override a tag with your own component

import Link from 'next/link'

<RichText
  data={content}
  components={{
    a: (props) => <Link {...props} className="underline underline-offset-4" />
  }}
/>

Blocks & inline blocks

<RichText
  data={content}
  components={{
    code: CodeBlock,
    image: ResponsiveImage
  }}
/>

process-nodes.ts — function tour

  • fontSizeClasses(node) — Reads inline style declarations like font-size-lg: … and returns the matching Tailwind utilities.
  • alignmentClasses(node) — Converts Lexical alignment flags into text-lefttext-centertext-right, or text-justify.
  • listClasses(node) — Chooses the correct list utility (list-disclist-decimallist-upper-alpha, …) from listType + indent.
  • applyIndent(attrs, node) — Writes --indent and returns ml-[calc(var(--indent)*1.875rem)] when the node isn’t a list/list‑item.
  • applyTableCell(attrs, node) — For table cells: sets rowSpancolSpan, and background colour.
  • getNodeAttributes(node, customClassName, parent) — Central helper that merges outputs from the helpers above, adds customClassName, and returns the attribute bag.
  • normalizeListNodes(nodes) — Fixes Lexical’s nested‑list quirk by putting sub‑lists inside the previous <li>.
  • processNodes(editorState) — Recursively runs normalizeListNodes on the JSON tree and returns the cleaned editor state. Exported as default.

use-rich-text.tsx — what happens inside?

  1. defaultComponents — plain React elements for every Lexical node (strongemh1h6ullitable, …). Override any of them via the components prop.
  2. createConverters(components, options) — builds the JSX converter map consumed by @payloadcms/richtext-lexical/react.
    • wraps text in a <span> when it carries inline styles/alignment
    • applies bold/italic/underline/etc. using the matching component
    • handles headings, lists, tables, uploads, blocks, and inline blocks
    • injects your classNamesblocksinlineBlocks, and lazy‑image settings
  3. Both defaultComponents and createConverters are exported so you can fork or extend the behaviour.

Font‑size utilities are optional and covered in Font Size Utilities elsewhere in the docs.