Apogée Consult
Retour au blog
Mathieu Ponton
Mathieu PontonCo-Founder & ingénieur logiciel

Je suis Mathieu Ponton, Co-Founder & ingénieur logiciel chez Apogée Consult à Lyon. Ingénieur diplômé de Polytech Lyon (Informatique), j'ai fait trois ans en apprentissage, partagés entre la Métropole de Lyon (inclusion numérique avec Res'in et sobriété énergétique avec Écolyo) et Superwyze, une startup medtech (POCs, dont certains aujourd'hui industrialisés, et travail sur des codebases existantes). J'ai livré plus de 10 projets en production (web, mobile et IA / RAG) pour des PME, startups et organisations publiques.

Custom Lexical blocks dans Payload 3 : ce qu'on aurait aimé savoir avant de commencer

  • payload

Créer des blocs Lexical custom dans Payload 3 n'est pas documenté en détail. Voici les points de friction réels et le pattern qui fonctionne en production.

Custom Lexical blocks dans Payload 3 : ce qu'on aurait aimé savoir avant de commencer

Le rich text dans Payload 3 s'appuie sur Lexical. Le principe est solide : un éditeur extensible, des blocs custom typés, un stockage JSON plutôt qu'HTML. Mais passer de "ça semble simple" à "ça tourne en prod" nous a pris trois fois plus de temps que prévu. Voici ce qu'on a appris.

Comment Lexical stocke le contenu

Avant d'écrire un bloc, il faut comprendre comment Payload sérialise le contenu Lexical. Ce n'est pas du HTML, pas du Markdown, c'est un arbre de noeuds JSON.

{
  "root": {
    "type": "root",
    "children": [
      {
        "type": "paragraph",
        "children": [{ "type": "text", "text": "Un paragraphe." }]
      },
      {
        "type": "block",
        "fields": {
          "blockType": "callout",
          "content": "Attention à ce point.",
          "variant": "warning"
        }
      }
    ]
  }
}

Un block dans Lexical est un noeud de type block dont le champ fields.blockType identifie le type de bloc. Le reste de fields correspond exactement aux champs que vous déclarez dans la collection Payload.

Déclarer un bloc custom

La configuration se fait dans payload.config.ts, au niveau du champ richText de la collection concernée.

// src/collections/Posts.ts
import { lexicalEditor, BlocksFeature } from '@payloadcms/richtext-lexical';
import { CalloutBlock } from '../blocks/Callout';

export const Posts: CollectionConfig = {
  slug: 'posts',
  fields: [
    {
      name: 'content',
      type: 'richText',
      editor: lexicalEditor({
        features: ({ defaultFeatures }) => [
          ...defaultFeatures,
          BlocksFeature({
            blocks: [CalloutBlock],
          }),
        ],
      }),
    },
  ],
};

Et le bloc lui-même :

// src/blocks/Callout.ts
import type { Block } from 'payload';

export const CalloutBlock: Block = {
  slug: 'callout',
  interfaceName: 'CalloutBlock',
  fields: [
    {
      name: 'content',
      type: 'textarea',
      required: true,
    },
    {
      name: 'variant',
      type: 'select',
      options: ['info', 'warning', 'danger'],
      defaultValue: 'info',
    },
  ],
};

C'est la partie documentée. La suite l'est beaucoup moins.

Piège 1 : le rendu côté serveur ne vient pas tout seul

Payload stocke le JSON Lexical. Il ne le transforme pas en HTML automatiquement lors des requêtes. Pour afficher le contenu riche côté front, il faut utiliser le package @payloadcms/richtext-lexical/react et son composant RichText.

// src/components/RichTextRenderer.tsx
import { RichText } from '@payloadcms/richtext-lexical/react';
import type { SerializedEditorState } from 'lexical';

interface Props {
  content: SerializedEditorState;
}

export function RichTextRenderer({ content }: Props) {
  return (
    <RichText
      data={content}
      converters={/* ... */}
    />
  );
}

La propriété converters est là où on branche les blocs custom. Sans converters déclarés, les noeuds de type block sont silencieusement ignorés, ils n'affichent rien, sans erreur.

Piège 2 : les converters sont des composants React, pas des fonctions

On a d'abord essayé de passer des fonctions de transformation simples. C'est une erreur : les converters Lexical côté React sont des composants React à part entière.

// src/converters/CalloutConverter.tsx
import type { JSXConverters } from '@payloadcms/richtext-lexical/react';
import type { CalloutBlock } from '@/payload-types';

export const CalloutConverter: JSXConverters = {
  blocks: {
    callout: ({ node }) => {
      const fields = node.fields as CalloutBlock;
      return (
        <div className={`callout callout--${fields.variant}`}>
          <p>{fields.content}</p>
        </div>
      );
    },
  },
};

Et dans le renderer :

import { CalloutConverter } from '@/converters/CalloutConverter';
import { defaultEditorConfig } from '@payloadcms/richtext-lexical/react';

<RichText
  data={content}
  converters={{
    ...defaultEditorConfig.converters,
    ...CalloutConverter,
  }}
/>

L'ordre du spread est important : les converters custom doivent surcharger les defaults, pas l'inverse.

Piège 3 : les types générés ne couvrent pas les champs Lexical imbriqués

payload generate:types génère les types pour les collections, mais les types des blocs Lexical dans le JSON stocké ne sont pas automatiquement injectés dans le type du champ richText. La propriété content est typée comme SerializedEditorState, un type Lexical générique, pas un type Payload spécifique à vos blocs.

Pour contourner ça, on crée des types manuels pour les node.fields dans les converters et on les caste explicitement :

// Dans le converter
const fields = node.fields as CalloutBlock; // cast nécessaire

C'est une limitation connue de l'intégration Payload/Lexical au moment où on écrit ces lignes. Un ticket ouvert dans le repo suggère une amélioration pour les versions futures.

Piège 4 : les blocs dans les blocs

Lexical permet d'imbriquer des blocs. Dans Payload, si un de vos blocs contient lui-même un champ richText, le rendu doit être récursif, et les converters doivent être passés à nouveau pour le rendu imbriqué.

On a factorisé ça dans un hook :

// src/hooks/useConverters.ts
export function useConverters() {
  return {
    ...defaultEditorConfig.converters,
    ...CalloutConverter,
    ...QuoteConverter,
    // ajouter ici chaque nouveau bloc
  };
}

De cette façon, les converters sont définis une seule fois et réutilisés dans tous les niveaux d'imbrication.

Ce qu'on aurait fait différemment

Créer tous les blocs dès le début dans un dossier src/blocks/ avec la convention : un fichier Definition.ts pour la config Payload, un fichier Converter.tsx pour le rendu React. La séparation est propre et les blocs restent indépendants.

src/
  blocks/
    Callout/
      Definition.ts    ← Block config Payload
      Converter.tsx    ← JSX Converter React
      index.ts         ← export des deux
    Quote/
      ...

On exporte CalloutBlock depuis Definition.ts pour l'enregistrement Payload, et CalloutConverter depuis Converter.tsx pour le rendu front. Pas de couplage entre les deux, facile à tester unitairement.

Ce que Lexical apporte vraiment

La vraie valeur de Lexical par rapport à Slate ou au rich text basique : l'arbre JSON est stable et versionnable. Migrer un champ HTML vers un autre format est douloureux. Migrer du JSON Lexical vers une nouvelle structure est faisable avec une migration Payload propre.

Pour des projets avec des contenus riches complexes, articles techniques, documentation, landing pages éditorialisées, ce choix vaut l'investissement de la mise en place.

La question reste ouverte : est-ce que la complexité des converters est justifiée pour des projets simples, ou existe-t-il un seuil en dessous duquel un champ textarea Markdown reste préférable ?

Disponible pour de nouveaux projets

Un projet à concrétiser ?
Parlons-en, sans engagement.

Un échange de 30 minutes pour cadrer votre besoin, qualifier la faisabilité et vous proposer une trajectoire claire.

1// kick-off : réponse sous 24h
2const project = await apogee.scope({
3 type: 'web | mobile | IA',
4 timeline: '6 à 16 semaines',
5 approach: 'sur-mesure'
6})
7// → cadrage offert
Lexical Payload custom block : guide d'implémentation | Apogée Consult