What it does
Editor.js is a block-style rich text editor for the web. Instead of producing an HTML blob like most WYSIWYG editors, it outputs clean JSON where each block is a typed object with its own data structure.
Why I starred it
Every WYSIWYG editor I've used eventually turns into a nightmare of HTML sanitization and unpredictable markup. Editor.js sidesteps that entirely. The output is a JSON array of blocks, each with a type and data field. No contenteditable soup. You can render that JSON anywhere — web, mobile, AMP, a speech reader, an LLM prompt. The data is portable in a way HTML never is.
The plugin architecture also caught my eye. The core ships with almost nothing — just the block management engine. Every content type (headings, images, lists, code blocks) is a separate npm package you opt into. Three production dependencies total: @editorjs/caret, codex-notifier, and codex-tooltip. That's it.
How it works
The entry point is src/codex.ts. The EditorJS constructor creates a Core instance and waits for it to be ready. Once initialized, it does something unusual — it uses Object.setPrototypeOf(this, editor.moduleInstances.API.methods) to graft the API module's methods directly onto the editor instance. That's how editor.save() and editor.render() work as top-level calls without explicit delegation.
The core bootstrap in src/components/core.ts follows a strict sequence. After configuration, it prepares modules in a specific order:
const modulesToPrepare = [
'Tools',
'UI',
'BlockManager',
'Paste',
'BlockSelection',
'RectangleSelection',
'CrossBlockSelection',
'ReadOnly',
];
Order matters here. Tools must prepare first because everything else depends on knowing which block types are available.
The real architecture lives in src/components/block/index.ts (1015 lines). Each Block instance wraps a tool adapter, manages its own DOM element, tracks inputs, and handles its own save/validate lifecycle. The save() method at line 544 is where it gets interesting — it calls the tool instance's save(), collects tune data from both custom and default tunes, then measures execution time with window.performance.now():
public async save(): Promise<undefined | SavedData> {
const extractedBlock = await this.toolInstance.save(
this.pluginsContent as HTMLElement
);
const tunesData: { [name: string]: BlockTuneData } = this.unavailableTunesData;
[...this.tunesInstances.entries(),
...this.defaultTunesInstances.entries()]
.forEach(([name, tune]) => {
if (_.isFunction(tune.save)) {
tunesData[name] = tune.save();
}
});
// ...
}
One detail I appreciate: this.unavailableTunesData preserves data from tunes that aren't currently loaded. If a block was created with a tune plugin that's no longer available, the data survives round-trips instead of being silently dropped.
The Renderer in src/components/modules/renderer.ts handles graceful degradation. When a block type isn't available, it doesn't crash — it swaps in a Stub tool that preserves the original data. This means you can render saved content even if some plugins are missing. The renderer also uses window.requestIdleCallback to wait for the browser to paint before resolving, which prevents layout thrashing on large documents.
Tool resolution happens through ToolsFactory in src/components/tools/factory.ts. It inspects static properties on the tool constructable — IsInline, IsTune — to determine the tool type and returns the appropriate adapter. Block tools get wrapped in BlockToolAdapter, inline tools in InlineToolAdapter. The factory pattern keeps the core completely agnostic about what tools actually do.
Change detection uses ModificationsObserver (backed by MutationObserver), which batches DOM mutations and deduplicates them by block ID and event type before firing onChange. There's a mutex pattern around fake cursor operations to prevent observer noise during programmatic DOM changes.
Using it
npm i @editorjs/editorjs @editorjs/header @editorjs/list
import EditorJS from '@editorjs/editorjs';
import Header from '@editorjs/header';
import List from '@editorjs/list';
const editor = new EditorJS({
holder: 'editor',
tools: {
header: Header,
list: List,
},
});
// Save returns clean JSON
const data = await editor.save();
// {
// time: 1702641600000,
// blocks: [
// { id: "abc123", type: "header", data: { text: "Hello", level: 2 } },
// { id: "def456", type: "paragraph", data: { text: "Some text." } }
// ],
// version: "2.31.6"
// }
Each block is a self-contained object. No nesting, no recursive HTML parsing. You can filter blocks by type, reorder them, or serialize them to any format without touching a DOM parser.
Rough edges
Collaborative editing is still on the roadmap — the CRDT/OT layer isn't there yet. For real-time multi-user editing, you'd need to build that yourself on top of the JSON output.
The test suite is Cypress-only (e2e). No unit tests for the core modules, which makes contributing riskier than it should be for a project this size. The BlockManager alone is 1020 lines — that's a lot of logic to validate through browser tests only.
The plugin ecosystem is fragmented. Official tools live in separate repos under editor-js/, each with their own release cycles. Finding which version of @editorjs/image works with which version of the core requires checking compatibility manually. There's no lockstep versioning.
Documentation exists but lags behind the codebase. Some API methods referenced in docs behave differently than the source suggests, particularly around block tunes and conversion configs.
Bottom line
If you need structured content editing where the output is JSON instead of HTML, Editor.js is the most mature option available. The plugin architecture is genuinely well-designed — lean core, isolated tools, clean data contracts. Best suited for CMS backends, content platforms, and anywhere you need to render the same content across multiple targets.
