← 목록

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

devto 2026-07-17 원문 보기 ↗


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:

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.