Render Payload CMS Rich Text with Tailwind v4 — Quick Guide
Set up Tailwind v4's typography plugin and Payload/Lexical converters to render custom rich text blocks and images.

📚 Comprehensive Payload CMS Guides
Detailed Payload guides with field configuration examples, custom components, and workflow optimization tips to speed up your CMS development process.
Related Posts:
I was recently upgrading a project to Payload 3.0 while simultaneously adopting Tailwind CSS v4. I hit a frustrating wall: my Rich Text content, which looked great in the editor, was rendering on the frontend as a bland wall of unstyled HTML. To make matters worse, my custom image blocks were either missing or breaking the layout.
After digging through the migration docs for both libraries, I realized the issue wasn't just about applying classes—it was about how Tailwind v4 registers plugins and how Lexical handles custom React components.
This guide walks you through setting up a robust Rich Text renderer that uses Tailwind's Typography plugin for beautiful defaults while seamlessly injecting custom components like Images.
1. Configuring Tailwind v4 for Typography
The first hurdle I encountered was that my prose classes weren't doing anything. In Tailwind v3, we added plugins in tailwind.config.js. In v4, configuration has moved almost entirely to CSS.
If you skip this step, no amount of React code will fix the styling. You need to explicitly import the typography plugin in your global CSS file.
/* File: src/app/globals.css */
@import "tailwindcss";
/* This line is required to activate the 'prose' utility classes in v4 */
@plugin "@tailwindcss/typography";
@theme {
/* Your other theme overrides */
}
By adding the @plugin directive, we unlock the prose classes. These classes provide expert-crafted typographic defaults (font size, line height, margins) to vanilla HTML elements, which is exactly what Payload's Rich Text outputs.
2. Creating the Custom Block Component
Before we build the renderer, we need the component we want to render. I wanted to insert images directly into the text flow but needed control over alignment, width, and rounded corners.
Here is the RichTextImage component. Note the use of the not-prose class—this is critical. Without it, the parent prose styles will target your figure and img tags, overriding your custom padding and margins with generic blog styles.
// File: src/components/blocks/richtext-image/richtext-image.tsx
'use client';
import { PayloadImageClient } from '@/components/payload/images/payload-image-client';
import { Media } from '@payload-types';
import { cn } from '@/lib/utils';
import { isObject } from '@/lib/type-guards';
export interface RichTextImageProps {
image: Media | number | null | undefined;
caption?: string;
width?: 'full' | 'large' | 'medium' | 'small';
alignment?: 'left' | 'center' | 'right';
rounded?: boolean;
className?: string;
}
export function RichTextImage({
image,
caption,
width = 'large',
alignment = 'center',
rounded = true,
className,
}: RichTextImageProps) {
// Ensure we don't crash if the image relation is broken
if (!isObject<Media>(image)) {
return null;
}
const widthClasses = {
full: 'w-full',
large: 'w-full lg:w-4/5',
medium: 'w-full lg:w-3/5',
small: 'w-full lg:w-2/5',
};
const alignmentClasses = {
left: 'mr-auto',
center: 'mx-auto',
right: 'ml-auto',
};
return (
<figure
className={cn(
// 'not-prose' protects this component from the parent typography styles
'not-prose my-8 flex flex-col gap-4',
widthClasses[width],
alignmentClasses[alignment],
className,
)}
>
<div className={cn('relative w-full aspect-auto', rounded && 'rounded-lg overflow-hidden bg-neutral-100')}>
<PayloadImageClient
image={image}
context="full" // Adjust based on your image component logic
fill={false}
className="w-full h-auto"
/>
</div>
{caption && <figcaption className="text-sm text-muted-foreground text-center">{caption}</figcaption>}
</figure>
);
}
This component accepts props directly from Payload's JSON structure. By handling the not-prose class here, we ensure that our layout choices (like w-4/5) are respected.
3. Building the RichText Renderer
Now we need the component that takes the raw JSON from Payload and transforms it into React components. Payload provides a RichText component, but we need to feed it a custom converters function to tell it how to handle our richtext_image block.
// File: src/components/rich-text-renderer.tsx
'use client';
import type { DefaultTypedEditorState } from '@payloadcms/richtext-lexical';
import { RichText as ConvertRichText, JSXConvertersFunction } from '@payloadcms/richtext-lexical/react';
import { RichTextImage } from '@/components/blocks/richtext-image/richtext-image';
import { cn } from '@/lib/utils';
type Props = {
data: DefaultTypedEditorState;
enableGutter?: boolean;
enableProse?: boolean;
} & React.HTMLAttributes<HTMLDivElement>;
const jsxConverters: JSXConvertersFunction = ({ defaultConverters }) => {
return {
...defaultConverters,
// We override the 'blocks' handling to catch our specific image block
blocks: {
...defaultConverters?.blocks,
richtext_image: ({ node }: { node: any }) => (
<RichTextImage
image={node.fields.image}
caption={node.fields.caption}
width={node.fields.width}
alignment={node.fields.alignment}
rounded={node.fields.rounded}
/>
),
},
};
};
export function RichTextRenderer({
data,
className,
enableProse = true,
enableGutter = true,
...rest
}: Props) {
if (!data) {
return null;
}
return (
<ConvertRichText
data={data}
converters={jsxConverters}
className={cn(
'payload-richtext break-words',
{
'container mx-auto': enableGutter,
'max-w-none': !enableGutter,
// The magic happens here: apply prose classes conditionally
'prose prose-sm md:prose-base lg:prose-lg dark:prose-invert': enableProse,
},
className,
)}
{...rest}
/>
);
}
In the jsxConverters function, we match the key richtext_image (which corresponds to the block slug in your Payload config) to our React component. We effectively swap the raw JSON node for our rendered RichTextImage.
4. Registering the Block in Payload Config
Finally, for this to work, the block must exist in your Payload Schema. This ensures the Content Editor sees the "Rich Text Image" option within the Slash menu of the editor.
// File: src/payload.config.ts (or wherever your config lives)
import { buildConfig } from 'payload/config';
import { lexicalEditor, BlocksFeature } from '@payloadcms/richtext-lexical';
import { RichTextImageBlock } from '@/blocks/richtext-image/schema'; // Your block schema definition
export default buildConfig({
// ... other config settings
editor: lexicalEditor({
features: ({ defaultFeatures }) => [
...defaultFeatures,
// Enable the blocks feature and register our custom block
BlocksFeature({
blocks: [RichTextImageBlock],
}),
],
}),
});
The BlocksFeature is what connects your schema definition to the Lexical editor. Once registered, Payload will store the data with the richtext_image slug, which our Renderer listens for on the frontend.
Conclusion
Rendering Rich Text doesn't have to be a choice between "ugly HTML" or "writing a parser from scratch." By combining Tailwind v4's typography plugin with Payload's Lexical converters, we get the best of both worlds: nice default typography for standard text, and full React control for complex blocks.
You now have a renderer you can drop into any page, knowing your images will look sharp and your text will be legible.
Let me know in the comments if you have questions, and subscribe for more practical development guides.
Thanks, Matija


