From 4253717b94c512d8624950f1a8f6e2fe338b8829 Mon Sep 17 00:00:00 2001 From: tobiasvdorp Date: Tue, 10 Feb 2026 17:13:20 +0100 Subject: [PATCH 1/2] feat(richtext-lexical): add custom component support to LinkJSXConverter and UploadJSXConverter Co-authored-by: Cursor --- docs/rich-text/converting-jsx.mdx | 122 +++++++++++++++++- .../converter/converters/link.tsx | 27 +++- .../converter/converters/upload.tsx | 72 ++++++++++- .../converter/defaultConverters.ts | 2 +- 4 files changed, 213 insertions(+), 10 deletions(-) diff --git a/docs/rich-text/converting-jsx.mdx b/docs/rich-text/converting-jsx.mdx index cf924d21a05..5e7414af7dc 100644 --- a/docs/rich-text/converting-jsx.mdx +++ b/docs/rich-text/converting-jsx.mdx @@ -81,6 +81,124 @@ export const MyComponent: React.FC<{ } ``` +### Custom Link Component + +By default, the link converter renders standard `` elements. If you want to use a framework-specific link component (e.g. Next.js `Link`), you can pass a `LinkComponent` to `LinkJSXConverter`: + +```tsx +import type { + DefaultNodeTypes, + SerializedLinkNode, +} from '@payloadcms/richtext-lexical' +import type { SerializedEditorState } from '@payloadcms/richtext-lexical/lexical' + +import { + type JSXConvertersFunction, + LinkJSXConverter, + RichText, +} from '@payloadcms/richtext-lexical/react' +import Link from 'next/link' +import React from 'react' + +const internalDocToHref = ({ linkNode }: { linkNode: SerializedLinkNode }) => { + const { relationTo, value } = linkNode.fields.doc! + if (typeof value !== 'object') { + throw new Error('Expected value to be an object') + } + const slug = value.slug + + switch (relationTo) { + case 'posts': + return `/posts/${slug}` + case 'pages': + return `/${slug}` + default: + return `/${relationTo}/${slug}` + } +} + +const jsxConverters: JSXConvertersFunction = ({ + defaultConverters, +}) => ({ + ...defaultConverters, + ...LinkJSXConverter({ + internalDocToHref, + LinkComponent: ({ href, children, ...rest }) => ( + + {children} + + ), + }), +}) + +export const MyComponent: React.FC<{ + lexicalData: SerializedEditorState +}> = ({ lexicalData }) => { + return +} +``` + +The `LinkComponent` receives `href`, `rel`, `target`, and `children` as props and is used for both autolink and link nodes. + +### Custom Upload Components + +The `UploadJSXConverter` accepts optional parameters for customizing how uploads are rendered: + +- **`ImageComponent`** – Custom component for rendering `` elements. Used for images without sizes, and as the fallback inside `` for images with sizes. +- **`LinkComponent`** – Custom component for rendering non-image file links. +- **`buildFullUrl`** – Function that transforms upload paths into absolute URLs (e.g. for cloud storage / CDN). + +```tsx +import type { DefaultNodeTypes } from '@payloadcms/richtext-lexical' +import type { SerializedEditorState } from '@payloadcms/richtext-lexical/lexical' + +import { + type JSXConvertersFunction, + RichText, + UploadJSXConverter, +} from '@payloadcms/richtext-lexical/react' +import Image from 'next/image' +import Link from 'next/link' +import React from 'react' + +const jsxConverters: JSXConvertersFunction = ({ + defaultConverters, +}) => ({ + ...defaultConverters, + ...UploadJSXConverter({ + buildFullUrl: (path) => `https://cdn.example.com${path}`, + LinkComponent: ({ href, children, ...rest }) => ( + + {children} + + ), + ImageComponent: ({ alt, height, src, width }) => ( + {alt + ), + }), +}) + +export const MyComponent: React.FC<{ + lexicalData: SerializedEditorState +}> = ({ lexicalData }) => { + return +} +``` + + + For images **with sizes**, the converter uses `{''}` with ` + {''}` tags. `ImageComponent` replaces the fallback `{''}` inside + `{''}` and is also used for images without sizes. `next/image` does + not support `{''}` natively, so this is the standard approach. + + +All parameters are optional — calling `UploadJSXConverter()` with no arguments produces the same default behavior as before. + ### Lexical Blocks If your rich text includes custom Blocks or Inline Blocks, you must supply custom converters that match each block's slug. This converter is not included by default, as Payload doesn't know how to render your custom blocks. @@ -137,7 +255,9 @@ export const MyComponent: React.FC<{ You can override any of the default JSX converters by passing your custom converter, keyed to the node type, to the `converters` prop / the converters function. -Example - overriding the upload node converter to use next/image: +For common customizations like using `next/image` or `next/link`, use the built-in component props on `UploadJSXConverter` and `LinkJSXConverter` as shown in the [Custom Upload Components](#custom-upload-components) and [Custom Link Component](#custom-link-component) sections above. + +For fully custom rendering logic, you can override a converter entirely by providing your own function keyed to the node type: ```tsx 'use client' diff --git a/packages/richtext-lexical/src/features/converters/lexicalToJSX/converter/converters/link.tsx b/packages/richtext-lexical/src/features/converters/lexicalToJSX/converter/converters/link.tsx index 6c8fe655303..bba67c17ca7 100644 --- a/packages/richtext-lexical/src/features/converters/lexicalToJSX/converter/converters/link.tsx +++ b/packages/richtext-lexical/src/features/converters/lexicalToJSX/converter/converters/link.tsx @@ -3,7 +3,16 @@ import type { JSXConverters } from '../types.js' export const LinkJSXConverter: (args: { internalDocToHref?: (args: { linkNode: SerializedLinkNode }) => string -}) => JSXConverters = ({ internalDocToHref }) => ({ + LinkComponent?: React.ComponentType<{ + children: React.ReactNode + href: string + rel?: string + target?: string + }> +}) => JSXConverters = ({ + internalDocToHref, + LinkComponent, +}) => ({ autolink: ({ node, nodesToJSX }) => { const children = nodesToJSX({ nodes: node.children, @@ -12,6 +21,14 @@ export const LinkJSXConverter: (args: { const rel: string | undefined = node.fields.newTab ? 'noopener noreferrer' : undefined const target: string | undefined = node.fields.newTab ? '_blank' : undefined + if (LinkComponent) { + return ( + + {children} + + ) + } + return ( {children} @@ -38,6 +55,14 @@ export const LinkJSXConverter: (args: { } } + if (LinkComponent) { + return ( + + {children} + + ) + } + return ( {children} diff --git a/packages/richtext-lexical/src/features/converters/lexicalToJSX/converter/converters/upload.tsx b/packages/richtext-lexical/src/features/converters/lexicalToJSX/converter/converters/upload.tsx index 927950d1dc8..f5a39381bf0 100644 --- a/packages/richtext-lexical/src/features/converters/lexicalToJSX/converter/converters/upload.tsx +++ b/packages/richtext-lexical/src/features/converters/lexicalToJSX/converter/converters/upload.tsx @@ -4,7 +4,25 @@ import type { SerializedUploadNode } from '../../../../../nodeTypes.js' import type { UploadDataImproved } from '../../../../upload/server/nodes/UploadNode.js' import type { JSXConverters } from '../types.js' -export const UploadJSXConverter: JSXConverters = { +export const UploadJSXConverter: (args?: { + buildFullUrl?: (path: string) => string + ImageComponent?: React.ComponentType<{ + alt?: string + height?: number + src: string + width?: number + }> + LinkComponent?: React.ComponentType<{ + children: React.ReactNode + href: string + rel?: string + target?: string + }> +}) => JSXConverters = ({ + buildFullUrl, + ImageComponent, + LinkComponent, +} = {}) => ({ upload: ({ node }) => { // TO-DO (v4): SerializedUploadNode should use UploadData_P4 const uploadNode = node as UploadDataImproved @@ -16,12 +34,20 @@ export const UploadJSXConverter: JSXConverters = { const alt = (uploadNode.fields?.alt as string) || (uploadDoc as { alt?: string })?.alt || '' - const url = uploadDoc.url + const url = buildFullUrl && uploadDoc.url ? buildFullUrl(uploadDoc.url) : uploadDoc.url /** * If the upload is not an image, return a link to the upload */ if (!uploadDoc.mimeType.startsWith('image')) { + if (LinkComponent) { + return ( + + {uploadDoc.filename} + + ) + } + return ( {uploadDoc.filename} @@ -33,6 +59,17 @@ export const UploadJSXConverter: JSXConverters = { * If the upload is a simple image with no different sizes, return a simple img tag */ if (!uploadDoc.sizes || !Object.keys(uploadDoc.sizes).length) { + if (ImageComponent) { + return ( + + ) + } + return {alt} } @@ -57,7 +94,9 @@ export const UploadJSXConverter: JSXConverters = { ) { continue } - const imageSizeURL = imageSize?.url + + const imageSizeURL = + buildFullUrl && imageSize.url ? buildFullUrl(imageSize.url) : imageSize.url pictureJSX.push( = { } // Add the default img tag - pictureJSX.push( - {alt}, - ) + if (ImageComponent) { + pictureJSX.push( + , + ) + } else { + pictureJSX.push( + {alt}, + ) + } + return {pictureJSX} }, -} +}) diff --git a/packages/richtext-lexical/src/features/converters/lexicalToJSX/converter/defaultConverters.ts b/packages/richtext-lexical/src/features/converters/lexicalToJSX/converter/defaultConverters.ts index f00439be97c..d6cdb78bd78 100644 --- a/packages/richtext-lexical/src/features/converters/lexicalToJSX/converter/defaultConverters.ts +++ b/packages/richtext-lexical/src/features/converters/lexicalToJSX/converter/defaultConverters.ts @@ -23,6 +23,6 @@ export const defaultJSXConverters: JSXConverters = { ...HorizontalRuleJSXConverter, ...ListJSXConverter, ...LinkJSXConverter({}), - ...UploadJSXConverter, + ...UploadJSXConverter(), ...TabJSXConverter, } From f24b1fc93cfe6f6e6e4c27f96c136b619b2e20d7 Mon Sep 17 00:00:00 2001 From: tobiasvdorp Date: Wed, 11 Feb 2026 10:31:39 +0100 Subject: [PATCH 2/2] chore: condense custom components documentation Co-authored-by: Cursor --- docs/rich-text/converting-jsx.mdx | 108 ++++++------------------------ 1 file changed, 22 insertions(+), 86 deletions(-) diff --git a/docs/rich-text/converting-jsx.mdx b/docs/rich-text/converting-jsx.mdx index 5e7414af7dc..c72fccfb791 100644 --- a/docs/rich-text/converting-jsx.mdx +++ b/docs/rich-text/converting-jsx.mdx @@ -81,97 +81,31 @@ export const MyComponent: React.FC<{ } ``` -### Custom Link Component +### Custom Components -By default, the link converter renders standard `` elements. If you want to use a framework-specific link component (e.g. Next.js `Link`), you can pass a `LinkComponent` to `LinkJSXConverter`: +Both `LinkJSXConverter` and `UploadJSXConverter` accept optional component props, so you can use framework-specific components (e.g. `next/link`, `next/image`) without overriding the full converter logic: ```tsx -import type { - DefaultNodeTypes, - SerializedLinkNode, -} from '@payloadcms/richtext-lexical' -import type { SerializedEditorState } from '@payloadcms/richtext-lexical/lexical' - import { type JSXConvertersFunction, LinkJSXConverter, - RichText, + UploadJSXConverter, } from '@payloadcms/richtext-lexical/react' +import Image from 'next/image' import Link from 'next/link' -import React from 'react' - -const internalDocToHref = ({ linkNode }: { linkNode: SerializedLinkNode }) => { - const { relationTo, value } = linkNode.fields.doc! - if (typeof value !== 'object') { - throw new Error('Expected value to be an object') - } - const slug = value.slug - - switch (relationTo) { - case 'posts': - return `/posts/${slug}` - case 'pages': - return `/${slug}` - default: - return `/${relationTo}/${slug}` - } -} -const jsxConverters: JSXConvertersFunction = ({ - defaultConverters, -}) => ({ +const jsxConverters: JSXConvertersFunction = ({ defaultConverters }) => ({ ...defaultConverters, ...LinkJSXConverter({ - internalDocToHref, + internalDocToHref: ({ linkNode }) => `/${linkNode.fields.doc?.value?.slug}`, LinkComponent: ({ href, children, ...rest }) => ( {children} ), }), -}) - -export const MyComponent: React.FC<{ - lexicalData: SerializedEditorState -}> = ({ lexicalData }) => { - return -} -``` - -The `LinkComponent` receives `href`, `rel`, `target`, and `children` as props and is used for both autolink and link nodes. - -### Custom Upload Components - -The `UploadJSXConverter` accepts optional parameters for customizing how uploads are rendered: - -- **`ImageComponent`** – Custom component for rendering `` elements. Used for images without sizes, and as the fallback inside `` for images with sizes. -- **`LinkComponent`** – Custom component for rendering non-image file links. -- **`buildFullUrl`** – Function that transforms upload paths into absolute URLs (e.g. for cloud storage / CDN). - -```tsx -import type { DefaultNodeTypes } from '@payloadcms/richtext-lexical' -import type { SerializedEditorState } from '@payloadcms/richtext-lexical/lexical' - -import { - type JSXConvertersFunction, - RichText, - UploadJSXConverter, -} from '@payloadcms/richtext-lexical/react' -import Image from 'next/image' -import Link from 'next/link' -import React from 'react' - -const jsxConverters: JSXConvertersFunction = ({ - defaultConverters, -}) => ({ - ...defaultConverters, ...UploadJSXConverter({ buildFullUrl: (path) => `https://cdn.example.com${path}`, - LinkComponent: ({ href, children, ...rest }) => ( - - {children} - - ), ImageComponent: ({ alt, height, src, width }) => ( {alt = ({ width={width ?? 0} /> ), + LinkComponent: ({ href, children, ...rest }) => ( + + {children} + + ), }), }) - -export const MyComponent: React.FC<{ - lexicalData: SerializedEditorState -}> = ({ lexicalData }) => { - return -} ``` - - For images **with sizes**, the converter uses `{''}` with ` - {''}` tags. `ImageComponent` replaces the fallback `{''}` inside - `{''}` and is also used for images without sizes. `next/image` does - not support `{''}` natively, so this is the standard approach. - +**`LinkJSXConverter` options:** + +- **`LinkComponent`** – Replaces `` for both autolink and link nodes. Receives `href`, `rel`, `target`, and `children`. + +**`UploadJSXConverter` options:** + +- **`ImageComponent`** – Replaces `` for images. For images with sizes, it replaces the fallback `` inside ``. +- **`LinkComponent`** – Replaces `` for non-image file uploads. +- **`buildFullUrl`** – Transforms upload paths into absolute URLs (e.g. for CDN). Applied to the main URL and all size URLs. -All parameters are optional — calling `UploadJSXConverter()` with no arguments produces the same default behavior as before. +All parameters are optional — calling `UploadJSXConverter()` with no arguments or `LinkJSXConverter({})` produces the same defaults as before. ### Lexical Blocks @@ -255,7 +191,7 @@ export const MyComponent: React.FC<{ You can override any of the default JSX converters by passing your custom converter, keyed to the node type, to the `converters` prop / the converters function. -For common customizations like using `next/image` or `next/link`, use the built-in component props on `UploadJSXConverter` and `LinkJSXConverter` as shown in the [Custom Upload Components](#custom-upload-components) and [Custom Link Component](#custom-link-component) sections above. +For common customizations like using `next/image` or `next/link`, use the built-in component props on `UploadJSXConverter` and `LinkJSXConverter` as shown in the [Custom Components](#custom-components) section above. For fully custom rendering logic, you can override a converter entirely by providing your own function keyed to the node type: