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.
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 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.
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.
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.
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.