This article explains the best practices for using rich text inline blocks in Payload CMS. Inline blocks allow you to insert dynamic data into content that can either be configured by users or fetched from other sources. When the value of this data changes, the content will automatically update wherever the block is used.
If you are only interested in the code, check out the sections: simple example, example with settings, configurable data instances, frontend implementation, inline rich text changes, GitHub.
Info
This article describes the usage of inline blocks that can only be used inside rich text. If you want to use them inside a single-line text field, you can use inline rich text.
Basic examples
Adding the Current Year
Let's start with a simple example: an inline block that outputs the current year dynamically. The main idea here is to avoid manually updating the year across your content every year. A common use case for this is the copyright year in the footer.
012345678
import { Block } from 'payload';
export const CURRENT_YEAR: Block = {
slug: 'currentYear',
fields: [],
};
export const INLINE_BLOCKS: Block[] = [CURRENT_YEAR];
012345678910111213141516
export default buildConfig({
...
editor: lexicalEditor({
features: ({ defaultFeatures }) => [
...defaultFeatures.filter(
(feature) => feature.key !== 'relationship' && feature.key !== 'checklist',
),
LinkFeature({
enabledCollections: ['pages'],
}),
BlocksFeature({
inlineBlocks: INLINE_BLOCKS,
}),
],
}),
...
});
Changes:
- Defined the Inline Block (1st tab)
ACURRENT_YEAR
variable was created as an inline block for rich text. This block did not require any fields. Additionally, anINLINE_BLOCKS
variable was defined to group all inline blocks for export. - Updated Payload Configuration (2nd tab)
Inpayload.config.ts
, thelexicalEditor
was used, and theCURRENT_YEAR
block was added to the appropriate parameter in theBlocksFeature
function provided by Payload CMS.
Adding a Configurable Block: Years Since a Specific Year
Next, let’s create an inline block that calculates the number of years since a specific moment. For example, user might want to display how many years their company has been in business without manually updating the content annually.
0123456789101112131415161718192021222324252627282930313233
import { Block } from 'payload';
export const CURRENT_YEAR: Block = {
slug: 'currentYear',
fields: [],
};
export const YEARS_FROM: Block = {
slug: 'yearsFrom',
fields: [
{
type: 'number',
name: 'year',
label: 'Year',
validate: (value: number | null | undefined) => {
const numberValue = Number(value);
if (numberValue < 0) {
return 'Year cannot be a negative number.';
}
if (numberValue > new Date().getFullYear()) {
return 'Year cannot be in the future.';
}
return true;
},
required: true,
},
],
};
export const INLINE_BLOCKS: Block[] = [CURRENT_YEAR, YEARS_FROM];
A new block, YEARS_FROM
, was added to inlineBlocks.ts
.
- This block includes a required field (
year
) where users input the start year. - Validation ensures the value is not in the future or negative.
- In the Payload CMS admin panel, users can input the start year. The system will dynamically calculate and display the elapsed years.
Here's what it looks like in the admin panel:
Making Inline Blocks User-Configurable: Data Instances
To empower clients to create and manage their own dynamic data, we can allow them to configure data instances without changing the code. For example, they could add dynamic values like "Number of Clients" or "Number of Countries."
012345678910111213141516171819202122232425262728293031
import type { CollectionConfig } from 'payload';
import { admins } from '@/payload/access/admins';
export const RichTextDataInstances: CollectionConfig = {
slug: 'richTextDataInstances',
admin: {
useAsTitle: 'name',
defaultColumns: ['name', 'value'],
},
access: {
read: () => true,
update: admins,
create: admins,
delete: admins,
},
fields: [
{
type: 'text',
name: 'name',
label: 'Name',
required: true,
},
{
type: 'text',
name: 'value',
label: 'Value',
required: true,
},
],
};
0123456789101112131415161718192021
...
export const DYNAMIC_DATA_INSTANCE: Block = {
slug: 'dynamicDataInstance',
fields: [
{
type: 'relationship',
relationTo: 'richTextDataInstances',
name: 'dataInstance',
label: 'Data instance',
required: true,
admin: {
components: {
Description: '@/payload/components/RichTextDataInstancesDescription/',
},
},
},
],
};
export const INLINE_BLOCKS: Block[] = [CURRENT_YEAR, YEARS_FROM, DYNAMIC_DATA_INSTANCE];
01234567891011121314151617
'use client';
import './styles.css';
const RichTextDataInstancesDescription = () => {
const clickHandler = () => {
window.open('/admin/collections/richTextDataInstances', '_blank');
};
return (
<div className="rich-text-data-instance-description">
To change or create data instance <u onClick={clickHandler}>click here</u>.
</div>
);
};
export default RichTextDataInstancesDescription;
0123456
.rich-text-data-instance-description {
margin-top: 8px;
}
.rich-text-data-instance-description u {
cursor: pointer;
}
Steps:
- Created a Collection: A new collection called
RichTextDataInstances
was defined, allowing users to create and edit data instances.
- This collection includes two required fields:
name
andvalue
. - The
name
field usesuseAsTitle
to display meaningful options in the selection dropdown.
- Added a Dynamic Block: The
inlineBlocks.ts
file was updated to include a new inline blockDYNAMIC_DATA_INSTANCE
.
- This block uses a dropdown (
select
) to let users choose from instances created in theRichTextDataInstances
collection. This field is required. - A description component (
RichTextDataInstancesDescription
) was added to guide users to the collection for creating or editing instances. Its purpose is to show the user where to create or modify rich text data instances. The source code of this component is shown on the third and fourth tabs.
Important!
Instead of using <a>
or Link
tags for navigation in the description, the <u>
tag was used. This is because Payload CMS prevents the default click behavior for standard link tags.
- User Workflow
Users can now:
- Create dynamic data instances in the admin panel.
- Select these instances while editing rich text content.
The process of creating a rich text instance and using it within the editor is demonstrated in the accompanying video.
Frontend implementation
The next step is to render inline blocks on the website. To do this you need to update the rich text rendering component. If you don’t already have one, you can use my example from GitHub. The changes concern the serialize
function, which is called by the RichText
component.
0123456789
...
if (_node.type === 'inlineBlock') {
return (
<InlineBlock
key={index}
{...(node as unknown as { fields: InlineBlocksType }).fields}
/>
);
}
...
012345678910111213141516171819202122232425262728293031323334
type CurrentYearBlock = {
blockType: 'currentYear';
};
type YearsFrom = {
blockType: 'yearsFrom';
year: number;
};
type DynamicDataInstance = {
blockType: 'dynamicDataInstance';
dataInstance: {
value: string;
};
};
export type InlineBlocksType = CurrentYearBlock | YearsFrom | DynamicDataInstance;
export const InlineBlock = (props: InlineBlocksType) => {
switch (props.blockType) {
case 'currentYear':
return <>{new Date().getFullYear()}</>;
case 'yearsFrom': {
const year = (props as YearsFrom).year;
return <>{new Date().getFullYear() - year}</>;
}
case 'dynamicDataInstance': {
const dataInstance = (props as DynamicDataInstance).dataInstance;
return <>{dataInstance.value}</>;
}
default:
return null;
}
};
The first tab shows changes in serialize
function. Checking for the inlineBlock
node type was added. Render a custom InlineBlock
component when this condition is met.
The second tab shows the source code of the InlineBlock
component. It handles three different inline block types:
currentYear
: Returns the current year usingnew Date().getFullYear()
.yearsFrom
: Calculates the difference between the current year and a specified starting year.dynamicDataInstance
: Displays the value from a selectedRichTextDataInstances
instance.
The result of this code is shown in the image below.
Using inline blocks in inline rich text
As mentioned earlier, you can also implement these changes for inline rich text, which is described in this article for single-line text fields. To do this, certain changes must be made to the inline rich text source code.
01234567891011121314151617181920212223242526272829303132333435363738394041424344
import { BlocksFeature, lexicalEditor, LinkFeature } from '@payloadcms/richtext-lexical';
import { RichTextField } from 'payload';
import { INLINE_BLOCKS } from '@/payload/fields/inlineBlocks';
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'],
}),
BlocksFeature({
inlineBlocks: INLINE_BLOCKS, // Adding new feature
}),
],
}),
admin: {
...admin,
components: {
Field: '@/payload/components/InlineRichText/',
},
},
...rest,
};
};
0123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476
'use client';
import {
AlignFeatureClient,
BlocksFeatureClient,
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,
},
blocks: { // new item added
clientFeatureProps: {
featureKey: 'blocks',
order: 8,
},
clientFeatureProvider: BlocksFeatureClient,
},
};
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',
},
],
},
blocks: { // new item added
[`${schemaPath}.lexical_internal_feature.blocks.lexical_inline_blocks.currentYear.fields.id`]:
[
{
name: 'id',
type: 'text',
admin: {
hidden: true,
},
label: 'ID',
},
],
[`${schemaPath}.lexical_internal_feature.blocks.lexical_inline_blocks.currentYear.fields.blockName`]:
[
{
name: 'blockName',
type: 'text',
admin: {
disabled: true,
},
label: 'Block Name',
required: false,
},
],
[`${schemaPath}.lexical_internal_feature.blocks.lexical_inline_blocks.currentYear.fields`]: [
{
name: 'id',
type: 'text',
admin: {
hidden: true,
},
label: 'ID',
},
{
name: 'blockName',
type: 'text',
admin: {
disabled: true,
},
label: 'Block Name',
required: false,
},
],
[`${schemaPath}.lexical_internal_feature.blocks.lexical_inline_blocks.currentYear`]: [
{
name: 'lexical_inline_blocks_currentYear',
type: 'blocks',
blocks: [
{
slug: 'currentYear',
fields: [
{
name: 'id',
type: 'text',
admin: {
hidden: true,
},
label: 'ID',
},
{
name: 'blockName',
type: 'text',
admin: {
disabled: true,
},
label: 'Block Name',
required: false,
},
],
labels: {
singular: 'Current Year',
plural: 'Current Years',
},
},
],
},
],
[`${schemaPath}.lexical_internal_feature.blocks.lexical_inline_blocks.yearsFrom.fields.year`]:
[
{
type: 'number',
name: 'year',
label: 'Year',
required: true,
admin: {},
},
],
[`${schemaPath}.lexical_internal_feature.blocks.lexical_inline_blocks.yearsFrom.fields.id`]: [
{
name: 'id',
type: 'text',
admin: {
hidden: true,
},
label: 'ID',
},
],
[`${schemaPath}.lexical_internal_feature.blocks.lexical_inline_blocks.yearsFrom.fields.blockName`]:
[
{
name: 'blockName',
type: 'text',
admin: {
disabled: true,
},
label: 'Block Name',
required: false,
},
],
[`${schemaPath}.lexical_internal_feature.blocks.lexical_inline_blocks.yearsFrom.fields`]: [
{
type: 'number',
name: 'year',
label: 'Year',
required: true,
admin: {},
},
{
name: 'id',
type: 'text',
admin: {
hidden: true,
},
label: 'ID',
},
{
name: 'blockName',
type: 'text',
admin: {
disabled: true,
},
label: 'Block Name',
required: false,
},
],
[`${schemaPath}.lexical_internal_feature.blocks.lexical_inline_blocks.yearsFrom`]: [
{
name: 'lexical_inline_blocks_yearsFrom',
type: 'blocks',
blocks: [
{
slug: 'yearsFrom',
fields: [
{
type: 'number',
name: 'year',
label: 'Year',
required: true,
admin: {},
},
{
name: 'id',
type: 'text',
admin: {
hidden: true,
},
label: 'ID',
},
{
name: 'blockName',
type: 'text',
admin: {
disabled: true,
},
label: 'Block Name',
required: false,
},
],
labels: {
singular: 'Years From',
plural: 'Years Froms',
},
},
],
},
],
[`${schemaPath}.lexical_internal_feature.blocks.lexical_inline_blocks.dynamicDataInstance.fields.dataInstance`]:
[
{
type: 'relationship',
relationTo: 'richTextDataInstances',
name: 'dataInstance',
label: 'Data instance',
required: true,
admin: {
components: {
Description: '@/payload/components/RichTextDataInstancesDescription/',
},
},
},
],
[`${schemaPath}.lexical_internal_feature.blocks.lexical_inline_blocks.dynamicDataInstance.fields.id`]:
[
{
name: 'id',
type: 'text',
admin: {
hidden: true,
},
label: 'ID',
},
],
[`${schemaPath}.lexical_internal_feature.blocks.lexical_inline_blocks.dynamicDataInstance.fields.blockName`]:
[
{
name: 'blockName',
type: 'text',
admin: {
disabled: true,
},
label: 'Block Name',
required: false,
},
],
[`${schemaPath}.lexical_internal_feature.blocks.lexical_inline_blocks.dynamicDataInstance.fields`]:
[
{
type: 'relationship',
relationTo: 'richTextDataInstances',
name: 'dataInstance',
label: 'Data instance',
required: true,
admin: {},
},
{
name: 'id',
type: 'text',
admin: {
hidden: true,
},
label: 'ID',
},
{
name: 'blockName',
type: 'text',
admin: {
disabled: true,
},
label: 'Block Name',
required: false,
},
],
[`${schemaPath}.lexical_internal_feature.blocks.lexical_inline_blocks.dynamicDataInstance`]: [
{
name: 'lexical_inline_blocks_dynamicDataInstance',
type: 'blocks',
blocks: [
{
slug: 'dynamicDataInstance',
fields: [
{
type: 'relationship',
relationTo: 'richTextDataInstances',
name: 'dataInstance',
label: 'Data instance',
required: true,
admin: {},
},
{
name: 'id',
type: 'text',
admin: {
hidden: true,
},
label: 'ID',
},
{
name: 'blockName',
type: 'text',
admin: {
disabled: true,
},
label: 'Block Name',
required: false,
},
],
labels: {
singular: 'Dynamic Data Instance',
plural: 'Dynamic Data Instances',
},
},
],
},
],
},
};
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;
In the inlineRichText.ts
file, all the changes are to add a BlocksFeature
with the same props as was done in payload.config.ts
. The following changes have been added for the inlineRichText
component:
- "blocks" was added to the
clientFeatures
object, which uses theBlocksFeatureClient
component provided by Payload CMS and passes the single prop "order" to it; - in the
featureClientSchemaMap
object was added "blocks" - an object describing fields for inline blocks. This value was copied from the corresponding value in common rich text using React Developer Tools.
This is how inline rich text with inline blocks looks in the admin panel:
Conclusion
In this article, we implemented rich text inline blocks and explored their functionality through examples:
currentYear
: A simple block to display the current year.yearsFrom
: A block with a configurable year field to calculate and display elapsed time.dynamicDataInstance
: A block allowing users to dynamically select and display values from a customRichTextDataInstances
collection.
Both the CMS configuration and the frontend rendering of inline blocks were covered. Additionally, it was demonstrated how these blocks can be used in inline rich text fields.
All the code from the article is available on GitHub. Feel free to use.