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.
- 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.
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:
01234567891011121314151617181920212223242526272829303132333435363738
import { 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,
};
};
01234567891011121314151617181920212223242526272829303132333435363738394041424344
import type { Block } from 'payload';
import { SECTION_LAYOUT } from '@/payload/fields/sectionLayout';
import { SIMPLE_RICH_TEXT } from '@/payload/fields/simpleRichText';
export const Hero: Block = {
imageURL: '/api/media/file/Hero.png',
slug: 'hero',
labels: {
singular: 'Hero',
plural: 'Heroes',
},
interfaceName: 'Hero',
fields: [
{
type: 'upload',
name: 'image',
label: 'Image',
relationTo: 'media',
required: true,
},
{
type: 'text',
name: 'heading',
label: 'Heading',
required: true,
},
{
type: 'text',
name: 'subheading',
label: 'Subheading',
},
SIMPLE_RICH_TEXT(), // simple rich text field usage
SECTION_LAYOUT({
breakpoints: [
{
minWidth: 767,
paddingTop: 0,
paddingBottom: 48,
},
],
}),
],
};
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.
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.
0123456789101112131415161718192021222324252627282930313233343536373839
import { 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,
};
};
0123456789101112131415161718192021222324252627282930313233343536373839
import type { Block } from 'payload';
import { INLINE_RICH_TEXT } from '@/payload/fields/inlineRichText';
import { SECTION_LAYOUT } from '@/payload/fields/sectionLayout';
import { SIMPLE_RICH_TEXT } from '@/payload/fields/simpleRichText';
export const Hero: Block = {
imageURL: '/api/media/file/Hero.png',
slug: 'hero',
labels: {
singular: 'Hero',
plural: 'Heroes',
},
interfaceName: 'Hero',
fields: [
{
type: 'upload',
name: 'image',
label: 'Image',
relationTo: 'media',
required: true,
},
INLINE_RICH_TEXT({
name: 'heading',
label: 'Heading',
required: true,
}),
SIMPLE_RICH_TEXT(),
SECTION_LAYOUT({
breakpoints: [
{
minWidth: 767,
paddingTop: 0,
paddingBottom: 48,
},
],
}),
],
};
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;
01234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950
.inline-rich-text {
margin-bottom: 20px;
}
.inline-rich-text .add-block-menu {
display: none;
}
.inline-rich-text .draggable-block-menu {
display: none;
}
.inline-rich-text .editor {
box-shadow: 0 2px 2px -1px #0000001a;
font-family: var(--font-body);
width: 100%;
border: 1px solid var(--theme-elevation-150);
border-radius: var(--style-radius-s);
background: var(--theme-input-bg);
color: var(--theme-elevation-800);
font-size: 1rem;
height: 40px;
line-height: 20px;
padding: 8px 15px;
-webkit-appearance: none;
transition-property: border, box-shadow, background-color;
transition-duration: .1s, .1s, .5s;
transition-timing-function: cubic-bezier(0,.2,.2,1);
}
.inline-rich-text .editor:focus-within {
border-color: var(--theme-elevation-400);
}
.inline-rich-text .editor p {
font-size: 1rem;
line-height: 20px;
}
.inline-rich-text .editor > div {
margin: 0;
padding: 0;
}
.inline-rich-text .editor .editor-placeholder {
display: none;
}
.inline-rich-text .editor [data-lexical-editor="true"] > *:not(:first-child) {
display: none;
}
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 forpath
.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 fieldhideGutter
. Iftrue
, it removes the indentation and hides the right border.permissions
: boolean field, here it is necessary to passtrue
, so that the field can be changed.clientFeatures
: configuration for determining which features will be used, passed as an object with the fieldsclientFeatureProvider
: pass the component represented by Payload CMS andclientFeatureProps
- props for the passed provider. Must containfeatureKey
(same as we used inINLINE_RICH_TEXT
) and order. For links, also passenabledCollections
to allow adding internal links.featureClientSchemaMap
: needed to build additional fields in certain fields. TheschemaPath
must also be passed to work. In our case the same map is used as in the usualrichText
with correction onshemaPath
.lexicalEditorConfig
: redefine the lexical editor CSS classes. Required prop, butundefined
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:
Frontend implementation
Frontend implementation is quite simple if you have an implementation of a regular rich text.
01234567891011121314151617181920212223242526272829303132
import 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>
);
};
0123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204
import type { SerializedListItemNode, SerializedListNode } from '@lexical/list';
import type { SerializedHeadingNode } from '@lexical/rich-text';
import type { LinkFields, SerializedLinkNode } from '@payloadcms/richtext-lexical';
import escapeHTML from 'escape-html';
import type { SerializedElementNode, SerializedLexicalNode, SerializedTextNode } from 'lexical';
import React, { Fragment } from 'react';
import {
IS_BOLD,
IS_CODE,
IS_ITALIC,
IS_STRIKETHROUGH,
IS_SUBSCRIPT,
IS_SUPERSCRIPT,
IS_UNDERLINE,
} from './nodeFormat';
import { Media } from '@/components/Media';
import { CMSLink } from '@/components/ui/CMSLink';
import { Link, Media as MediaType } from '@/payload-types';
import { cn } from '@/utils/cn';
import { toKebabCase } from '@/utils/toKebabCase';
interface Props {
nodes: SerializedLexicalNode[];
inline?: boolean;
}
export function serializeLexical({ nodes, inline = false }: Props) {
return (
<Fragment>
{nodes?.map((_node, index) => {
const node = _node as SerializedTextNode;
const format = ['left', 'right', 'center', 'justify'].includes(String(node.format))
? 'text-' + String(node.format)
: undefined;
if (_node.type === 'text') {
let text: React.JSX.Element | string = escapeHTML(node.text);
if (node.format & IS_BOLD) {
text = <strong key={index}>{text}</strong>;
}
if (node.format & IS_ITALIC) {
text = <em key={index}>{text}</em>;
}
if (node.format & IS_STRIKETHROUGH) {
text = (
<span key={index} style={{ textDecoration: 'line-through' }}>
{text}
</span>
);
}
if (node.format & IS_UNDERLINE) {
text = (
<span key={index} style={{ textDecoration: 'underline' }}>
{text}
</span>
);
}
if (node.format & IS_CODE) {
text = <code key={index}>{text}</code>;
}
if (node.format & IS_SUBSCRIPT) {
text = <sub key={index}>{text}</sub>;
}
if (node.format & IS_SUPERSCRIPT) {
text = <sup key={index}>{text}</sup>;
}
return text;
}
const serializedChildrenFn = (node: SerializedElementNode) => {
if (node.children == null) {
return null;
} else {
if (node?.type === 'list' && (node as SerializedListNode)?.listType === 'check') {
for (const item of node.children) {
if ('checked' in item) {
if (!item?.checked) {
item.checked = false;
}
}
}
return serializeLexical({ nodes: node.children, inline });
} else {
return serializeLexical({ nodes: node.children, inline });
}
}
};
const serializedChildren =
'children' in _node ? serializedChildrenFn(_node as SerializedElementNode) : '';
if (_node.type === 'link') {
const node = _node as SerializedLinkNode;
const fields: LinkFields = node.fields;
return (
<CMSLink
key={index}
newTab={Boolean(fields.newTab)}
disableIndex={Boolean(fields.newTab)}
reference={fields.doc as Link['reference']}
type={fields.linkType === 'internal' ? 'reference' : 'custom'}
url={fields.url}
>
{serializedChildren}
</CMSLink>
);
}
if (inline) {
return <Fragment key={index}>{serializedChildren}</Fragment>;
}
switch (_node.type) {
case 'linebreak': {
if (inline) return <Fragment key={index} />;
return <br key={index} />;
}
case 'paragraph': {
return (
<p key={index} className={format}>
{serializedChildren}
</p>
);
}
case 'heading': {
const node = _node as SerializedHeadingNode;
type Heading = Extract<keyof JSX.IntrinsicElements, 'h1' | 'h2' | 'h3' | 'h4' | 'h5'>;
const Tag = node?.tag as Heading;
return (
<Tag
key={index}
className={format}
id={toKebabCase(
String((node.children[0] as unknown as { text: string }).text ?? ''),
)}
>
{serializedChildren}
</Tag>
);
}
case 'label':
return (
<p className={format} key={index}>
{serializedChildren}
</p>
);
case 'list': {
const node = _node as SerializedListNode;
type List = Extract<keyof JSX.IntrinsicElements, 'ol' | 'ul'>;
const Tag = node?.tag as List;
return (
<Tag className={cn(node?.listType, format)} key={index}>
{serializedChildren}
</Tag>
);
}
case 'listitem': {
const node = _node as SerializedListItemNode;
return (
<li className={format} key={index} value={node?.value}>
{serializedChildren}
</li>
);
}
case 'quote': {
return (
<blockquote key={index} className={format}>
{serializedChildren}
</blockquote>
);
}
case 'horizontalrule': {
return <hr key={index} />;
}
case 'upload': {
const source = (node as unknown as { value: MediaType }).value;
return (
<Media
source={source}
className="mx-auto"
sizes="(min-width: 1440px) 1408px, (min-width: 768px) calc(100vw - 64px), calc(100vw - 32px)"
/>
);
}
default:
return null;
}
})}
</Fragment>
);
}
0123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657
import React from 'react';
import { Media } from '@/components/Media';
import { RichText } from '@/components/RichText';
import { Section } from '@/components/Section';
import { SectionHeading } from '@/components/SectionHeading';
import { Hero as HeroType } from '@/payload-types';
import { cn } from '@/utils/cn';
import { Block } from '@/utils/types';
export type HeroProps = Block<HeroType>;
export const Hero = ({ image, heading, description, isFirst, sectionLayout }: HeroProps) => {
const loading = isFirst ? 'eager' : 'lazy';
return (
<Section {...sectionLayout}>
<div className="container">
<div
className={cn(
'max-w-[500px] mx-auto flex flex-col gap-4',
'sm:max-w-full sm:flex-row sm:gap-8 sm:items-center',
'md:gap-14',
)}
>
{image && (
<div className="sm:w-[calc(50%-16px)]">
<Media
className="w-full rounded-full my-0"
sizes="(min-width: 768px) 50vw, (min-width: 1440px) 692px, 100vw"
source={image}
width={414}
height={414}
loading={loading}
/>
</div>
)}
{Boolean(heading || description) && (
<div className={cn('text-center', 'sm:text-left sm:w-[calc(50%-16px)]')}>
{heading && (
<SectionHeading
isFirst={isFirst}
className={cn('h1 mb-4 font-normal', 'sm:md-8 sm:text-left')}
>
<RichText content={heading} inline />
</SectionHeading>
)}
{description && <RichText content={description} />}
</div>
)}
</div>
</div>
</Section>
);
};
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.
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.
All the code from the article is available on GitHub. Feel free to use.