Building a Block Editor in React Without a Library
Instead of adopting a large rich text editor library, ShabelleHub's CMS uses a simple, purpose-built block editor.
The ShabelleHub CMS needed a content editor. The obvious options were Slate, TipTap, or Quill — mature rich text editor libraries with large feature sets and active communities.
We built a simple block editor from scratch instead. This article explains the decision and the implementation.
Why Not a Rich Text Editor Library
Rich text editor libraries are large dependencies with complex internals. Slate's bundle adds roughly 100KB. TipTap's core is lighter but grows with extensions. Both have their own document models, event systems, and rendering layers that sit between your React component tree and the DOM.
For ShabelleHub's content — primarily prose, code blocks, and headings, with the occasional image — the full power of a rich text editor is not needed. The editing operations are simple: type text, add a code block, insert a heading, upload an image. None of these require inline formatting, nested lists, tables, or the collaborative editing features that justify a library's complexity.
The trade-off we made: a simpler editor that exactly covers the use case, with no dependency to maintain and no bundle size impact.
The Block Model
Content is stored as an array of block objects:
// Block types
const BLOCK_TYPES = {
PARAGRAPH: 'paragraph',
HEADING: 'heading',
CODE: 'code',
IMAGE: 'image',
QUOTE: 'quote',
DIVIDER: 'divider',
};
// Example document
const blocks = [
{ id: '1', type: 'heading', level: 2, content: 'Introduction' },
{ id: '2', type: 'paragraph', content: 'First paragraph text.' },
{ id: '3', type: 'code', language: 'js', content: 'const x = 1;' },
{ id: '4', type: 'image', src: 'https://...', alt: 'Screenshot' },
];
Each block has an id (for React keys and drag-to-reorder), a type, and type-specific fields. This structure serialises cleanly to JSON for database storage and maps directly to HTML for rendering.
The Editor Component
The top-level editor manages the blocks array and provides operations for adding, updating, and removing blocks:
// components/admin/PostEditor.js
import { useState, useCallback } from 'react';
import Block from './Block';
import AddBlockMenu from './AddBlockMenu';
export default function PostEditor({ initialBlocks = [], onChange }) {
const [blocks, setBlocks] = useState(initialBlocks);
const updateBlock = useCallback((id, updates) => {
setBlocks(prev => {
const next = prev.map(b => b.id === id ? { ...b, ...updates } : b);
onChange?.(next);
return next;
});
}, [onChange]);
const addBlock = useCallback((type, afterId) => {
const newBlock = {
id: crypto.randomUUID(),
type,
content: '',
...(type === 'heading' ? { level: 2 } : {}),
...(type === 'code' ? { language: 'js' } : {}),
};
setBlocks(prev => {
const idx = afterId ? prev.findIndex(b => b.id === afterId) : prev.length - 1;
const next = [...prev.slice(0, idx + 1), newBlock, ...prev.slice(idx + 1)];
onChange?.(next);
return next;
});
}, [onChange]);
const removeBlock = useCallback((id) => {
setBlocks(prev => {
const next = prev.filter(b => b.id !== id);
onChange?.(next);
return next;
});
}, [onChange]);
return (
<div>
{blocks.map(block => (
<Block
key={block.id}
block={block}
onUpdate={updates => updateBlock(block.id, updates)}
onRemove={() => removeBlock(block.id)}
onAddAfter={type => addBlock(type, block.id)}
/>
))}
<AddBlockMenu onAdd={type => addBlock(type, null)} />
</div>
);
}
The onChange callback fires on every update, passing the current blocks array to the parent (the post editor page), which saves it to the database on autosave or manual save.
Individual Block Components
Each block type renders as a different editing component:
// components/admin/Block.js
import ParagraphBlock from './blocks/ParagraphBlock';
import HeadingBlock from './blocks/HeadingBlock';
import CodeBlock from './blocks/CodeBlock';
import ImageBlock from './blocks/ImageBlock';
import QuoteBlock from './blocks/QuoteBlock';
const BLOCK_COMPONENTS = {
paragraph: ParagraphBlock,
heading: HeadingBlock,
code: CodeBlock,
image: ImageBlock,
quote: QuoteBlock,
};
export default function Block({ block, onUpdate, onRemove, onAddAfter }) {
const Component = BLOCK_COMPONENTS[block.type];
if (!Component) return null;
return (
<div style={{ position: 'relative', marginBottom: 16 }}>
<Component block={block} onUpdate={onUpdate} />
<BlockActions onRemove={onRemove} onAddAfter={onAddAfter} />
</div>
);
}
Paragraph Block
// components/admin/blocks/ParagraphBlock.js
export default function ParagraphBlock({ block, onUpdate }) {
return (
<textarea
value={block.content}
onChange={e => onUpdate({ content: e.target.value })}
placeholder="Start writing..."
style={{
width: '100%',
minHeight: 80,
background: 'none',
border: 'none',
outline: 'none',
resize: 'vertical',
fontSize: 15,
lineHeight: 1.7,
color: 'var(--text)',
fontFamily: 'inherit',
}}
/>
);
}
Code Block
// components/admin/blocks/CodeBlock.js
export default function CodeBlock({ block, onUpdate }) {
return (
<div style={{ background: 'var(--card)', borderRadius: 8, overflow: 'hidden' }}>
<div style={{ display: 'flex', gap: 8, padding: '8px 12px', borderBottom: '1px solid var(--border)' }}>
<select
value={block.language || 'js'}
onChange={e => onUpdate({ language: e.target.value })}
style={{ background: 'none', border: 'none', color: 'var(--muted)', fontSize: 12 }}
>
{['js', 'ts', 'jsx', 'tsx', 'css', 'html', 'sql', 'bash', 'json'].map(lang => (
<option key={lang} value={lang}>{lang}</option>
))}
</select>
</div>
<textarea
value={block.content}
onChange={e => onUpdate({ content: e.target.value })}
spellCheck={false}
style={{
width: '100%',
minHeight: 120,
padding: '12px 16px',
background: 'none',
border: 'none',
outline: 'none',
resize: 'vertical',
fontFamily: 'monospace',
fontSize: 13,
lineHeight: 1.6,
color: 'var(--text)',
}}
/>
</div>
);
}
Image Block
// components/admin/blocks/ImageBlock.js
import { useState } from 'react';
export default function ImageBlock({ block, onUpdate }) {
const [uploading, setUploading] = useState(false);
async function handleFile(e) {
const file = e.target.files[0];
if (!file) return;
setUploading(true);
const base64 = await fileToBase64(file);
const res = await fetch('/api/admin/media/upload', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ base64, name: file.name, type: file.type, size: file.size }),
});
const { url } = await res.json();
onUpdate({ src: url });
setUploading(false);
}
return (
<div>
{block.src ? (
<div>
<img src={block.src} alt={block.alt || ''} style={{ maxWidth: '100%', borderRadius: 8 }} />
<input
value={block.alt || ''}
onChange={e => onUpdate({ alt: e.target.value })}
placeholder="Alt text (required for accessibility)"
style={{ marginTop: 8, width: '100%' }}
/>
</div>
) : (
<label style={{ display: 'block', padding: 32, border: '2px dashed var(--border)', borderRadius: 8, textAlign: 'center', cursor: 'pointer' }}>
{uploading ? 'Uploading...' : 'Click to upload image'}
<input type="file" accept="image/*" onChange={handleFile} style={{ display: 'none' }} />
</label>
)}
</div>
);
}
Rendering Blocks as HTML
The block array is rendered to HTML for blog post pages:
// lib/cms/renderBlocks.js
export function renderBlocks(blocks) {
return blocks.map(block => {
switch (block.type) {
case 'paragraph':
return `<p>${escapeHtml(block.content)}</p>`;
case 'heading':
return `<h${block.level}>${escapeHtml(block.content)}</h${block.level}>`;
case 'code':
return `<pre><code class="language-${block.language}">${escapeHtml(block.content)}</code></pre>`;
case 'image':
return `<figure><img src="${block.src}" alt="${escapeHtml(block.alt || '')}" loading="lazy" /></figure>`;
case 'quote':
return `<blockquote>${escapeHtml(block.content)}</blockquote>`;
case 'divider':
return '<hr />';
default:
return '';
}
}).join('\n');
}
escapeHtml sanitises user content before it is written into HTML. This is essential — any content that reaches innerHTML without sanitisation is an XSS risk.
What the Custom Editor Does Not Do
Being honest about limitations:
- No inline formatting — no bold, italic, or links within a paragraph. These would require either a contenteditable element (complex) or markdown parsing (simpler but less WYSIWYG).
- No drag-to-reorder — blocks can be deleted and re-added in a different position, but dragging is not implemented.
- No collaborative editing — one editor at a time.
- No undo/redo beyond the browser default —
textareaelements have native undo for individual blocks, but undoing a block deletion requires re-adding it.
For ShabelleHub's publishing volume and single-author workflow, these limitations are acceptable. For a multi-author publication with complex formatting needs, one of the library-based editors would be the right choice.
*This article is part of the ShabelleHub Building in Public series.*
📬 Get the latest AI tool reviews
Expert picks and comparisons, weekly. No spam.