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.

·Updated on:·Matija Žiberna·
Render Payload CMS Rich Text with Tailwind v4 — Quick Guide

📚 Comprehensive Payload CMS Guides

Detailed Payload guides with field configuration examples, custom components, and workflow optimization tips to speed up your CMS development process.

No spam. Unsubscribe anytime.

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

4

Frequently Asked Questions

Comments

Leave a Comment

Your email will not be published

10-2000 characters

• Comments are automatically approved and will appear immediately

• Your name and email will be saved for future comments

• Be respectful and constructive in your feedback

• No spam, self-promotion, or off-topic content

Matija Žiberna
Matija Žiberna
Full-stack developer, co-founder

I'm Matija Žiberna, a self-taught full-stack developer and co-founder passionate about building products, writing clean code, and figuring out how to turn ideas into businesses. I write about web development with Next.js, lessons from entrepreneurship, and the journey of learning by doing. My goal is to provide value through code—whether it's through tools, content, or real-world software.

You might be interested in