The small JavaScript renderer below supports paragraphs and headings from level 2 through 6. It treats every text field as literal text. That makes its behavior easy to inspect, but it deliberately does not reproduce inline formatting, lists, images, or custom tools.
Start from the saved document
Editor.js documents editor.save() as an asynchronous operation returning a document with time, blocks, and version. Blocks contain a type and tool-specific data. The tool configuration determines the type names and data shape, so inspect a real saved record from your application before writing its renderer. Editor.js saving data
This is an original fixture for this guide, not output captured from a live editing session:
{
"blocks": [
{"type":"header","data":{"text":"Pickup & returns","level":2}},
{"type":"paragraph","data":{"text":"Bring the blue tag <before> closing."}},
{"type":"paragraph","data":{"text":"<strong>Call first</strong> if the tag is missing."}}
]
}
The third block exposes an important decision. An Editor.js text field can contain inline HTML. In this renderer, the reader sees the literal characters <strong>Call first</strong>, including the tag names. They do not see bold text. Choose that behavior deliberately rather than accidentally accepting arbitrary markup.
If your input is a JSON string, parse it once with JSON.parse inside your application's error handling. If the backend already returned an object, pass the object directly. Parsing JSON text covers that boundary.
Use a small, explicit renderer
This function returns an HTML fragment. Your page supplies the document shell, article title, and CSS. The article title can remain the page's h1; the saved body starts at h2.
function renderEditorData(documentData) {
if (!documentData || !Array.isArray(documentData.blocks)) {
throw new Error("Expected an Editor.js blocks array");
}
const escapes = {
"&": "&", "<": "<", ">": ">",
'"': """, "'": "'"
};
const escapeText = text =>
text.replace(/[&<>"']/g, character => escapes[character]);
return documentData.blocks.map((block, index) => {
if (!block || !block.data ||
typeof block.data.text !== "string") {
throw new Error(`Block ${index + 1}: expected text data`);
}
const text = escapeText(block.data.text);
if (block.type === "paragraph") {
return `<p>${text}</p>`;
}
if (block.type === "header") {
const level = block.data.level;
if (!Number.isInteger(level) || level < 2 || level > 6) {
throw new Error(`Block ${index + 1}: heading level must be 2–6`);
}
return `<h${level}>${text}</h${level}>`;
}
throw new Error(`Block ${index + 1}: unsupported block type`);
}).join("\n");
}
Only the function chooses element names. Text cannot supply an element or an attribute, and an unchecked heading level cannot become part of a tag. The replacement turns HTML-significant characters into character references. MDN's character-reference explanation describes how those references represent characters in rendered HTML.
The returned fragment for the fixture is:
<h2>Pickup & returns</h2>
<p>Bring the blue tag <before> closing.</p>
<p><strong>Call first</strong> if the tag is missing.</p>
In a browser, the heading reads “Pickup & returns.” The next paragraph retains <before> as visible text, and the final paragraph retains the literal <strong> tags. A source string that already contains & will likewise display the characters &; this adapter does not decode entities or recover rich-text meaning.
Reject an incomplete conversion
Put the returned fragment into the published result only after the whole function succeeds. If a later block fails, the function throws instead of returning the earlier paragraphs as a seemingly complete article. Show the error in your application's text error display and retain the previous published revision or leave publication pending.
For example, a third block of {"type":"image","data":{"url":"photo.png"}} fails with a block-three error because it has no supported text field. A third block of {"type":"quote","data":{"text":"Call first"}} fails as an unsupported type. Neither image nor quotation should quietly disappear from the article.
The exact function above was executed in Node.js against the fixture. Its HTML matched the three lines shown above. Separate checks confirmed rejection of an unsupported quote block, an image block, a nonstring text value, and heading levels outside the declared range. Those checks establish this function's output and rejection behavior; they do not test an Editor.js installation or your publishing route.
Add formatting only when you have defined its rules
If the source needs bold text and links, this literal-text renderer is too limited. Add a separately reviewed adapter for the allowed inline markup rather than deleting the escaping step. Define allowed elements, attributes, and URL schemes, and test the sanitizer used at your rendering boundary.
Editor.js also provides sanitization rules for tool fields that contain HTML, including integration with enabled inline tools. Those saving rules depend on the tool configuration. They do not turn every JSON record arriving at your publishing endpoint into trusted HTML. Sanitizing saved Editor.js data
Extend one block type at a time using its actual saved schema. A list may contain nested items; an image may need alternative text and an approved source rule. Keep an unsupported-block error until you can represent the content adequately. Do not substitute a blank element just to make publication succeed.
Compare the article with its source before publishing
Check the block order, heading levels, text, and any reported unsupported content against the retained JSON. Open the finished page at its intended reading width and on a phone. A valid fragment still needs the surrounding page's typography and layout.
Keep the JSON as the editable source. If you already produce a standalone HTML review copy, you can read that copy in NOS while checking it against the saved blocks. Changes to a review copy do not update the Editor.js record; make lasting content corrections in your CMS and render the revised source again.