Skip to content

Instantly share code, notes, and snippets.

@elliott-w
Last active September 9, 2025 10:16
Show Gist options
  • Save elliott-w/6c2e98bdf158a0b1ba4de1e3fd73d225 to your computer and use it in GitHub Desktop.
Save elliott-w/6c2e98bdf158a0b1ba4de1e3fd73d225 to your computer and use it in GitHub Desktop.
Payload CMS - Stores richText content as a string rather than a raw object
import {
traverseFields,
type CollectionConfig,
type GlobalConfig,
type Plugin,
type RichTextField,
type TraverseFieldsCallback,
} from 'payload'
import { richText } from 'payload/shared'
// Helper function to detect if an object is a Lexical rich text structure
const isLexicalRichText = (value: any): boolean => {
return (
value &&
typeof value === 'object' &&
value.root &&
typeof value.root === 'object' &&
Array.isArray(value.root.children)
)
}
// Helper function to detect if a string is a stringified rich text
const isStringifiedRichText = (value: string): boolean => {
return value.startsWith('{"root":')
}
// Helper function to check if rich text data needs processing
const needsStringification = (value: any): boolean => {
return isLexicalRichText(value)
}
const needsParsing = (value: any): boolean => {
return typeof value === 'string' && isStringifiedRichText(value)
}
// Create field-level hooks for richText fields
const addRichTextHooks = (field: RichTextField): void => {
const originalValidate = field.validate
field.validate = async (value: any, options: any) => {
const incomingValue = value as unknown as string | undefined
const parsedValue = incomingValue ? JSON.parse(incomingValue) : undefined
const result = await originalValidate?.(parsedValue, options)
if (typeof result === 'string') {
return result
}
return richText(parsedValue, {
...options,
name: field.name,
type: 'richText',
})
}
if (!field.hooks) {
field.hooks = {}
}
field.hooks.beforeChange = [
...(field.hooks.beforeChange || []),
async ({ value, req }) => {
try {
// Only stringify if it's currently a rich text object
if (needsStringification(value)) {
return JSON.stringify(value)
}
// If it's already a string or other format, leave it alone (backwards compatible)
return value
} catch (error) {
req.payload.logger.error(
`RichTextStringify: Error in field beforeChange for ${field.name}:`,
error
)
return value
}
},
]
field.hooks.afterRead = [
...(field.hooks.afterRead || []),
async ({ value, req }) => {
try {
// Parse stringified rich text back to objects
if (needsParsing(value)) {
return JSON.parse(value)
}
// If it's already an object (legacy format), leave it alone (backwards compatible)
return value
} catch (error) {
req.payload.logger.error(
`RichTextStringify: Error in field afterRead for ${field.name}:`,
error
)
// Keep original value if parsing fails
return value
}
},
]
}
interface RichTextStringifyPluginArgs {
/**
* Whether to enable the plugin.
* @default true
*/
enable?: boolean
}
// Main plugin function
export const richTextStringifyPlugin = ({
enable = true,
}: RichTextStringifyPluginArgs = {}): Plugin => {
return (config) => {
if (!enable) {
return config
}
const addRichTextHooksCallback: TraverseFieldsCallback = ({ field }) => {
if (field.type === 'richText') {
addRichTextHooks(field as RichTextField)
}
}
// Process collections to add field-level hooks
;(config.collections || []).forEach((collection: CollectionConfig) => {
traverseFields({
fields: collection.fields,
callback: addRichTextHooksCallback,
})
})
// Process globals to add field-level hooks
config.globals = (config.globals || []).map((global: GlobalConfig) => {
traverseFields({
fields: global.fields,
callback: addRichTextHooksCallback,
})
return global
})
return config
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment