Building a custom Contentful Rich Text editor: the round-trip problem
DEV Community

Building a custom Contentful Rich Text editor: the round-trip problem

Contentful lets you replace the UI of a Rich Text field with your own App. That sounds like a front-end job - build a nicer editor, mount it in the field location, done. It isn't.

The editor UI is the easy 20%. The hard 80% is a small file most people never think about: the layer that converts between your editor's internal model and Contentful's stored document - losslessly, in both directions. I built one on top of PlateJS (which is a Slate editor underneath), and this post is about that conversion layer - the part that actually decides whether the whole thing works.

Two models that don't match

Contentful Rich Text is a fixed JSON schema (@contentful/rich-text-types): a document whose content is a tree of blocks, inlines and text nodes, where formatting lives on text nodes as an array of marks:

{
  "nodeType": "document",
  "content": [
    {
      "nodeType": "paragraph",
      "content": [
        { "nodeType": "text", "value": "Hello ", "marks": [] },
        { "nodeType": "text", "value": "world", "marks": [{ "type": "bold" }] }
      ]
    }
  ]
}

Slate/Plate uses a different shape for the same idea: { type, children } elements, and text nodes where marks are just boolean props:

[
  { "type": "p", "children": [
    { "text": "Hello " },
    { "text": "world", "bold": true }
  ]}
]

Same document, two representations. The editor speaks Plate; the field stores Contentful. So you need two functions:

Contentful document ──deserialize()──▢ Plate value ──▢ editor field
◀──serialize()──── Plate value ◀── (debounced onChange)

And they have to be exact inverses. If serialize(deserialize(doc)) isn't equal to doc, then simply opening an entry and saving it - without touching anything - silently corrupts content. That's the round-trip problem.

The design decision that made it tractable

The temptation is to write a clever generic converter. I did the opposite: I made the two models mirror each other on purpose, so the transform is a thin, explicit mapping instead of a guessing game.

Plate node types are named to shadow Contentful's node types - p, h1, ul, li, blockquote, embedded-entry-block - so the mapping is just two lookup tables that are literal inverses of each other:

const BLOCK_TO_CF: Record<string, string> = {
  p: BLOCKS.PARAGRAPH,
  h1: BLOCKS.HEADING_1,
  h2: BLOCKS.HEADING_2,
  h3: BLOCKS.HEADING_3,
  blockquote: BLOCKS.QUOTE,
  hr: BLOCKS.HR,
  ul: BLOCKS.UL_LIST,
  ol: BLOCKS.OL_LIST,
  li: BLOCKS.LIST_ITEM,
};

// the inverse falls out for free
const CF_TO_BLOCK = Object.fromEntries(
  Object.entries(BLOCK_TO_CF).map(([k, v]) => [v, k]),
);

Marks get the same treatment - a bold mark on a Contentful text node becomes a bold: true prop on a Plate leaf, and back:

const MARK_PROPS = ["bold", "italic", "underline", "code"] as const;

const MARK_TO_CF: Record<(typeof MARK_PROPS)[number], string> = {
  bold: MARKS.BOLD,
  italic: MARKS.ITALIC,
  underline: MARKS.UNDERLINE,
  code: MARKS.CODE,
};

const CF_TO_MARK = Object.fromEntries(
  Object.entries(MARK_TO_CF).map(([k, v]) => [v, k]),
);

Deriving each inverse table with Object.fromEntries instead of hand-writing it isn't just less typing - it makes the inverse structurally guaranteed. You can't forget to add the reverse mapping for a node type, because there's only one source of truth.

The edge cases are the whole game

A demo with a bold paragraph round-trips fine. Real content breaks on the corners. The ones that mattered:

Empty nodes

Slate requires every element to have at least one child. Contentful is fine with an empty block. So deserializing has to backfill:

const children = node.content.map(cfNodeToPlate).filter(Boolean);
const ensureChildren = children.length ? children : [{ text: "" }];

Skip this and Slate throws the moment it tries to render an empty paragraph.

Void nodes - embeds

An embedded entry or asset has no editable text; it's a card that resolves its title through the CMA. In Slate terms that's a void node, and voids still need a dummy text child to satisfy the schema:

case BLOCKS.EMBEDDED_ENTRY:
  return {
    type: "embedded-entry-block",
    entryId: node.data?.target?.sys?.id, // carry the id, nothing else
    children: [{ text: "" }], // required, even though it's void
  };

The only thing you keep is the entry id. Everything visible about that card is resolved live - get the transform wrong here and you either lose the reference or store an invalid node the CDA rejects.

Unsupported node types

A generic editor doesn't implement every Contentful block. The safe move is to skip what you don't understand rather than crash or fabricate:

const plateType = CF_TO_BLOCK[node.nodeType];
if (!plateType) return null; // skip gracefully

Contentful-valid nesting

Lists have to be ul > li > p, not ul > li with raw text. If the editor produces the wrong nesting, the value looks fine in Plate and fails validation only when you save. The mapping has to produce schema-valid structure, not just structurally-similar structure.

Pin it with round-trip tests

None of this is provable by clicking around. The transform is a pure function with no React and no Contentful SDK in sight, which means it's trivially unit-testable - and the test that matters is the round-trip identity:

// for a representative document:
expect(serialize(deserialize(doc))).toEqual(doc);

Because the module is framework-free, the tests run in plain node --test without booting the editor. That's the payoff of keeping the conversion layer isolated: the riskiest part of the app is also the cheapest part to test exhaustively.

The takeaway

If you build a custom Contentful editor - or any editor that has to persist to a schema you don't control - treat the conversion layer as the core of the project, not an afterthought. Concretely:

  • Make the two models mirror each other so the mapping is explicit, not inferred.
  • Derive inverse tables from one source so you can't desync them.
  • Enumerate the edge cases - empty nodes, void nodes, unsupported types, valid nesting - because that's where round-trip breaks.
  • Isolate it from the framework and pin it with round-trip tests, since silent corruption is invisible until it isn't.

The editor UI is what people see. The transform is what keeps their content intact.

The full editor is open source - code and a live demo. It's part of a set of Contentful/Next.js projects I keep public at vitaliipopov.dev.

Comments

No comments yet. Start the discussion.