Rich Text
Turn a Payload rich‑text field into clean HTML, ready‑styled with Tailwind, and still fully overrideable.
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 (h1
,p
,ul
…).- 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 likefont-size-lg: …
and returns the matching Tailwind utilities. - alignmentClasses(node) — Converts Lexical alignment flags into
text-left
,text-center
,text-right
, ortext-justify
. - listClasses(node) — Chooses the correct list utility (
list-disc
,list-decimal
,list-upper-alpha
, …) fromlistType
+indent
. - applyIndent(attrs, node) — Writes
--indent
and returnsml-[calc(var(--indent)*1.875rem)]
when the node isn’t a list/list‑item. - applyTableCell(attrs, node) — For table cells: sets
rowSpan
,colSpan
, 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?
defaultComponents
— plain React elements for every Lexical node (strong
,em
,h1
–h6
,ul
,li
,table
, …). Override any of them via thecomponents
prop.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
classNames
,blocks
,inlineBlocks
, and lazy‑image settings
- wraps text in a
- Both
defaultComponents
andcreateConverters
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.