This article explains how to create an inline rich text field in Payload CMS, including creating a custom component for the admin panel and implementing it on the frontend.

If you are only interested in the code, check out the sections: field in Payload, custom field, frontend implementation, GitHub.

Problem

Every web developer working with CMS has likely encountered situations where single-line fields require formatting. For instance, a designer (or client) may request bold or italic text in a header. In such cases, two common yet problematic solutions come to mind:

  • Use a full-fledged rich text field: This option risks users inserting multiple paragraphs or even images, leading to an unintuitive UI and potential issues with SEO or broken markup.
Richtext as simple text field
Richtext as simple text field
  • Use a simple text field with HTML markup: I used this solution as the lesser of two evils. Here are the obvious disadvantages: the content manager should not know how HTML works, and therefore there is a high risk of incorrect markup, which at best will break the output and at worst will cause hydration error. Moreover, in such a case, dangerouslySetInnerHTML must be used, which could potentially expose the site to XSS attacks.
Text field with HTML markup
Text field with HTML markup

Neither of these solutions aligns with best practices, so I came up with the idea to create a special field and component for such cases.

In fact, such a field exists in Shopify and is called inline_richtext. So, I got inspiration from it. When developing this kind of customization, we will first make a simple rich text - a richText field where you can only change formatting and add lists and links. This will be an intermediate result between simple rich text and inline rich text, which can also often come in handy when creating customizations for sections.

Simple rich text

Let's start with simple rich text. It is quite easy to make this setting - there is an editor setting for this purpose. The field will look like this:

01234567891011121314151617181920212223242526272829303132333435363738import { lexicalEditor, LinkFeature } from '@payloadcms/richtext-lexical'; import { RichTextField } from 'payload'; const SIMPLE_RICH_TEXT_FIELDS = [ 'toolbarInline', 'bold', 'italic', 'underline', 'strikethrough', 'superscript', 'inlineCode', 'paragraph', 'heading', 'unorderedList', 'orderedList', 'align', ]; export const SIMPLE_RICH_TEXT = ( overrides?: Omit<RichTextField, 'editor' | 'type'>, ): RichTextField => { const { name = 'description', label = 'Description', ...rest } = overrides ?? {}; return { type: 'richText', name, label, editor: lexicalEditor({ features: ({ defaultFeatures }) => [ ...defaultFeatures.filter(({ key }) => SIMPLE_RICH_TEXT_FIELDS.includes(key)), LinkFeature({ enabledCollections: ['pages'], }), ], }), ...rest, }; };

So, we have created SIMPLE_RICH_TEXT, a function that allows you to override any RichTextField except type (it is always “richText”) and editor (the point of the field is to configure it). The const SIMPLE_RICH_TEXT_FIELDS lists the feature keys that will be available in the admin panel. You can modify this array to suit your needs, for example, inlineCode is not needed for most web sites unless it is a programming blog. You can read the list of features in the documentation. LinkFeature is handled separately to specify collections for internal links. Also Payload provides an opportunity to add your own blocks.

Important!

Make sure you have enabled the toolbarInline feature. Without it, inline features such as bold, italic, link, etc will not work.

The second tab shows the basic use of SIMPLE_RICH_TEXT. Since no arguments were passed to the function, the field will get the name “description” and label "Description". The image below shows what this setting looks like in the admin panel.

Simple rich text field in admin panel
Simple rich text field in admin panel

The best part about this is that you can use the same component for the frontend implementation as you would for a regular richText field. An example of such a component can be found on GitHub.

Inline rich text field

The next step will be the implementation of inline rich text using the experience with simple rich text. The main difference is that for inline rich text we need the field to be a single line, and for this we need to use a custom component in the admin panel. I don't know any other CMS that allows this level of flexibility, but Payload does it.

0123456789101112131415161718192021222324252627282930313233343536373839import { lexicalEditor, LinkFeature } from '@payloadcms/richtext-lexical'; import { RichTextField } from 'payload'; const INLINE_RICH_TEXT_FIELDS = [ 'toolbarInline', 'bold', 'italic', 'underline', 'strikethrough', 'inlineCode', 'align', ]; export const INLINE_RICH_TEXT = ( overrides?: Omit<RichTextField, 'editor' | 'type'>, ): RichTextField => { const { name = 'text', label = 'Text', admin, ...rest } = overrides ?? {}; return { type: 'richText', name, label, editor: lexicalEditor({ features: ({ defaultFeatures }) => [ ...defaultFeatures.filter(({ key }) => INLINE_RICH_TEXT_FIELDS.includes(key)), LinkFeature({ enabledCollections: ['pages'], }), ], }), admin: { ...admin, components: { Field: '@/payload/components/InlineRichText/', }, }, ...rest, }; };

So, INLINE_RICH_TEXT is similar to SIMPLE_RICH_TEXT in most aspects.

The key differences:

  • Default values for name and label changed to "text" and "Text".
  • Block features are removed from lexicalEditor.
  • A custom component "@/payload/components/InlineRichText/" adjusts the admin panel’s UI to resemble a single-line text field, limiting the input to one block.

Building a custom component

To build an inline rich text component we, will use the RichTextField component provided by Payload CMS and used to render rich text fields.

0123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183'use client'; import { AlignFeatureClient, BoldFeatureClient, InlineCodeFeatureClient, InlineToolbarFeatureClient, ItalicFeatureClient, LinkFeatureClient, RichTextField, StrikethroughFeatureClient, UnderlineFeatureClient, } from '@payloadcms/richtext-lexical/client'; import { useField } from '@payloadcms/ui'; import { ComponentProps, useEffect } from 'react'; import './styles.css'; type RichTextFieldProps = ComponentProps<typeof RichTextField>; type RichTextValue = { root: { type: string; children: { type: string; version: number; [k: string]: unknown; }[]; direction: ('ltr' | 'rtl') | null; format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | ''; indent: number; version: number; }; [k: string]: unknown; }; const InlineRichText = ({ field, path, schemaPath, }: { field: RichTextFieldProps['field']; path: RichTextFieldProps['path']; schemaPath: string; }) => { const clientFeatures = { toolbarInline: { clientFeatureProps: { featureKey: 'toolbarInline', order: 0, }, clientFeatureProvider: InlineToolbarFeatureClient, }, link: { clientFeatureProps: { enabledCollections: ['pages'], featureKey: 'link', order: 1, }, clientFeatureProvider: LinkFeatureClient, }, align: { clientFeatureProps: { featureKey: 'align', order: 2, }, clientFeatureProvider: AlignFeatureClient, }, inlineCode: { clientFeatureProps: { featureKey: 'inlineCode', order: 3, }, clientFeatureProvider: InlineCodeFeatureClient, }, strikethrough: { clientFeatureProps: { featureKey: 'strikethrough', order: 4, }, clientFeatureProvider: StrikethroughFeatureClient, }, underline: { clientFeatureProps: { featureKey: 'underline', order: 5, }, clientFeatureProvider: UnderlineFeatureClient, }, bold: { clientFeatureProps: { featureKey: 'bold', order: 6, }, clientFeatureProvider: BoldFeatureClient, }, italic: { clientFeatureProps: { featureKey: 'italic', order: 7, }, clientFeatureProvider: ItalicFeatureClient, }, }; const featureClientSchemaMap = { link: { [`${schemaPath}.lexical_internal_feature.link.fields`]: [ { name: 'text', type: 'text', label: 'Text to display', required: true, }, { name: 'linkType', type: 'radio', label: 'Link Type', options: [ { label: 'Custom URL', value: 'custom', }, { label: 'Internal Link', value: 'internal', }, ], required: true, }, { name: 'url', type: 'text', label: 'Enter a URL', required: true, }, { name: 'doc', type: 'relationship', label: 'Choose a document to link to', relationTo: ['pages'], required: true, }, { name: 'newTab', type: 'checkbox', label: 'Open in new tab', }, ], }, }; const { value, setValue } = useField<RichTextValue>({ path }); useEffect(() => { const children = value?.root?.children; if (children && children.length > 1) { setValue({ root: { ...(value.root ?? {}), children: [children[0]], }, }); } }, [value]); return ( <div className="inline-rich-text"> <RichTextField path={path} schemaPath={schemaPath} initialLexicalFormState={{}} field={field} admin={{ hideGutter: true }} permissions={true} clientFeatures={clientFeatures} featureClientSchemaMap={ featureClientSchemaMap as RichTextFieldProps['featureClientSchemaMap'] } lexicalEditorConfig={undefined} /> </div> ); }; export default InlineRichText;

So, RichTextField accepts such props:

  • path: the path to the current field (e.g., "layout.0.heading"). Required for Payload CMS to understand which field to update. It is passed to all fields connected to the field via props.
  • schemaPath: the schema field path (e.g., "pages.layouthero.heading"). Purpose and source are the same as for path.
  • initialLexicalFormState: initial value of the field. We pass an empty object because the field is required.
  • field: field settings. It is needed to render label, "*" for required and validation. It is passed to all fields connected to the field via props.
  • admin: object with boolean field hideGutter. If true, it removes the indentation and hides the right border.
  • permissions: boolean field, here it is necessary to pass true, so that the field can be changed.
  • clientFeatures: configuration for determining which features will be used, passed as an object with the fields
  • clientFeatureProvider: pass the component represented by Payload CMS and clientFeatureProps - props for the passed provider. Must contain featureKey (same as we used in INLINE_RICH_TEXT) and order. For links, also pass enabledCollections to allow adding internal links.
  • featureClientSchemaMap: needed to build additional fields in certain fields. The schemaPath must also be passed to work. In our case the same map is used as in the usual richText with correction on shemaPath.
  • lexicalEditorConfig: redefine the lexical editor CSS classes. Required prop, but undefined can be passed, so that default classes are used.

Important!

Despite the fact that schemaPath is an optional prop, it must be passed in for featureClientSchemaMap to work.

To limit rich text to one block, use the useField hook from Payload CMS. Use useEffect to monitor changes, removing extra blocks when more than one is detected (e.g., when pressing Enter).

The second tab shows the CSS styles to make our component look like a text field. Most of the styles were simply copied from a regular text field.

In the end, the inline rich text field looks like this:

Inline rich text field in admin panel
Inline rich text field in admin panel

Frontend implementation

Frontend implementation is quite simple if you have an implementation of a regular rich text.

01234567891011121314151617181920212223242526272829303132import type { SerializedLexicalNode } from 'lexical'; import React, { ComponentProps } from 'react'; import { serializeLexical } from './serialize'; import { cn } from '@/utils/cn'; import { RichTextType } from '@/utils/types'; type RichTextProps = Omit<ComponentProps<'div'>, 'content'> & { content?: RichTextType; inline?: boolean; }; export const RichText = ({ className, content, inline = false, ...rest }: RichTextProps) => { if (!content) { return null; } const hasRoot = 'root' in content; if (!hasRoot) { return null; } const contentTyped = content as { root: { children: SerializedLexicalNode[] } }; return ( <div className={cn('rich-text', className)} {...rest}> {serializeLexical({ nodes: contentTyped.root.children, inline })} </div> ); };

The frontend implementation is straightforward if you’ve already set up regular rich text. Simply add an inline prop to both RichText and serializeLexical. When inline is true, exclude block tags like paragraphs, headings, and lists. The appearance of inline rich text is shown in the image below.

Inline rich text website appearance
Inline rich text website appearance

Conclusion

In this article, we built simple rich text, which is rich text with limited features, as well as inline rich text, which limits rich text to only inline features and uses a custom component in admin panel and modified RichText component on the website to render inline rich text.

New feature

The usage of inline blocks in inline rich text is described in this article.

All the code from the article is available on GitHub. Feel free to use.