Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 57 additions & 1 deletion docs/rich-text/converting-jsx.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,60 @@ export const MyComponent: React.FC<{
}
```

### Custom Components

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 JSXConvertersFunction,
LinkJSXConverter,
UploadJSXConverter,
} from '@payloadcms/richtext-lexical/react'
import Image from 'next/image'
import Link from 'next/link'

const jsxConverters: JSXConvertersFunction = ({ defaultConverters }) => ({
...defaultConverters,
...LinkJSXConverter({
internalDocToHref: ({ linkNode }) => `/${linkNode.fields.doc?.value?.slug}`,
LinkComponent: ({ href, children, ...rest }) => (
<Link href={href} {...rest}>
{children}
</Link>
),
}),
...UploadJSXConverter({
buildFullUrl: (path) => `https://cdn.example.com${path}`,
ImageComponent: ({ alt, height, src, width }) => (
<Image
alt={alt ?? ''}
height={height ?? 0}
src={src}
width={width ?? 0}
/>
),
LinkComponent: ({ href, children, ...rest }) => (
<Link href={href} {...rest}>
{children}
</Link>
),
}),
})
```

**`LinkJSXConverter` options:**

- **`LinkComponent`** – Replaces `<a>` for both autolink and link nodes. Receives `href`, `rel`, `target`, and `children`.

**`UploadJSXConverter` options:**

- **`ImageComponent`** – Replaces `<img>` for images. For images with sizes, it replaces the fallback `<img>` inside `<picture>`.
- **`LinkComponent`** – Replaces `<a>` 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 or `LinkJSXConverter({})` produces the same defaults 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.
Expand Down Expand Up @@ -137,7 +191,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 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:

```tsx
'use client'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,16 @@ import type { JSXConverters } from '../types.js'

export const LinkJSXConverter: (args: {
internalDocToHref?: (args: { linkNode: SerializedLinkNode }) => string
}) => JSXConverters<SerializedAutoLinkNode | SerializedLinkNode> = ({ internalDocToHref }) => ({
LinkComponent?: React.ComponentType<{
children: React.ReactNode
href: string
rel?: string
target?: string
}>
}) => JSXConverters<SerializedAutoLinkNode | SerializedLinkNode> = ({
internalDocToHref,
LinkComponent,
}) => ({
autolink: ({ node, nodesToJSX }) => {
const children = nodesToJSX({
nodes: node.children,
Expand All @@ -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 (
<LinkComponent href={node.fields.url ?? ''} rel={rel} target={target}>
{children}
</LinkComponent>
)
}

return (
<a href={node.fields.url} {...{ rel, target }}>
{children}
Expand All @@ -38,6 +55,14 @@ export const LinkJSXConverter: (args: {
}
}

if (LinkComponent) {
return (
<LinkComponent href={href} rel={rel} target={target}>
{children}
</LinkComponent>
)
}

return (
<a href={href} {...{ rel, target }}>
{children}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<SerializedUploadNode> = {
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<SerializedUploadNode> = ({
buildFullUrl,
ImageComponent,
LinkComponent,
} = {}) => ({
upload: ({ node }) => {
// TO-DO (v4): SerializedUploadNode should use UploadData_P4
const uploadNode = node as UploadDataImproved
Expand All @@ -16,12 +34,20 @@ export const UploadJSXConverter: JSXConverters<SerializedUploadNode> = {

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 (
<LinkComponent href={url ?? ''} rel="noopener noreferrer">
{uploadDoc.filename}
</LinkComponent>
)
}

return (
<a href={url} rel="noopener noreferrer">
{uploadDoc.filename}
Expand All @@ -33,6 +59,17 @@ export const UploadJSXConverter: JSXConverters<SerializedUploadNode> = {
* 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 (
<ImageComponent
alt={alt}
height={uploadDoc.height}
src={url ?? ''}
width={uploadDoc.width}
/>
)
}

return <img alt={alt} height={uploadDoc.height} src={url} width={uploadDoc.width} />
}

Expand All @@ -57,7 +94,9 @@ export const UploadJSXConverter: JSXConverters<SerializedUploadNode> = {
) {
continue
}
const imageSizeURL = imageSize?.url

const imageSizeURL =
buildFullUrl && imageSize.url ? buildFullUrl(imageSize.url) : imageSize.url

pictureJSX.push(
<source
Expand All @@ -70,9 +109,28 @@ export const UploadJSXConverter: JSXConverters<SerializedUploadNode> = {
}

// Add the default img tag
pictureJSX.push(
<img alt={alt} height={uploadDoc?.height} key={'image'} src={url} width={uploadDoc?.width} />,
)
if (ImageComponent) {
pictureJSX.push(
<ImageComponent
alt={alt}
height={uploadDoc?.height}
key={'image'}
src={url ?? ''}
width={uploadDoc?.width}
/>,
)
} else {
pictureJSX.push(
<img
alt={alt}
height={uploadDoc?.height}
key={'image'}
src={url}
width={uploadDoc?.width}
/>,
)
}

return <picture>{pictureJSX}</picture>
},
}
})
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,6 @@ export const defaultJSXConverters: JSXConverters<DefaultNodeTypes> = {
...HorizontalRuleJSXConverter,
...ListJSXConverter,
...LinkJSXConverter({}),
...UploadJSXConverter,
...UploadJSXConverter(),
...TabJSXConverter,
}