A modern, lightweight WYSIWYG rich text editor for React & Next.js
Feature-flagged · Fully typed · Zero runtime dependencies · <300KB
| Feature | Description |
|---|---|
| Visual editor | contenteditable WYSIWYG — no iframe, no Flash |
| Block mode | Drag-and-drop block editor with per-block type selector |
| Text / HTML mode | Raw HTML editing with syntax highlighting |
| HTML & Markdown | onChange fires in whichever format you choose |
| Feature flags | Enable or disable every toolbar button individually |
| Media upload | Wire any S3 / MinIO / custom API — just pass callbacks |
| Plugins | Link checker, tables, image editor, history, YouTube embed, subscript/superscript |
| Fully typed | End-to-end TypeScript with imperative ref handle |
| Zero deps | No runtime dependencies beyond React |
npm install mandoo-editor
# or
yarn add mandoo-editor
# or
pnpm add mandoo-editor
⚠️ Required — add this import wherever you use the editor:
import 'mandoo-editor/styles';Add it in your layout, page, or component — wherever MandooEditor is rendered. Without it the editor has no styling.
"use client";
import MandooEditor from "mandoo-editor";
export default function MyPage() {
return (
<MandooEditor
defaultValue="<p>Start writing...</p>"
onChange={(html) => console.log(html)}
height={400}
/>
);
}###rops
| Prop | Type | Default | Description |
|---|---|---|---|
value |
string |
— | Controlled HTML value |
defaultValue |
string |
'' |
Uncontrolled initial HTML value |
onChange |
(value: string) => void |
— | Fires on every change with current value |
outputFormat |
'html' | 'markdown' |
'html' |
Format for onChange and getValue() |
placeholder |
string |
'Start writing…' |
Placeholder shown when empty |
tabs |
TabId[] |
['visual','text','block'] |
Which tabs to display |
defaultTab |
TabId |
'visual' |
Initially active tab |
features |
Features |
all enabled | Granular toolbar feature flags |
plugins |
Plugins |
none | Optional plugin flags |
media |
MediaConfig |
— | File upload / library config |
height |
number |
400 |
Min height of editor content area (px) |
className |
string |
— | Extra CSS class on root element |
apiToken |
string |
— | Token for future paid pro features |
###mperative Handle (ref)
import { useRef } from "react";
import MandooEditor, { MandooEditorHandle } from "mandoo-editor";
const ref = useRef<MandooEditorHandle>(null);
// Methods:
ref.current?.getValue(); // → string (respects outputFormat)
ref.current?.getHTML(); // → raw HTML string
ref.current?.getMarkdown(); // → Markdown string
ref.current?.setValue(html); // set content programmatically
ref.current?.focus(); // focus the editor
ref.current?.clear(); // clear contentMandooEditor outputs HTML or Markdown. There are two ways to use it in a form:
Add a name prop and a hidden <input> is automatically rendered. Works with any form library or native HTML form submission.
// Native HTML form
<form action="/api/save" method="POST">
<MandooEditor name="content" outputFormat="html" />
<button type="submit">Save</button>
</form>
// Next.js Server Action
async function save(formData: FormData) {
'use server';
const content = formData.get('content'); // ← HTML or Markdown
}
<form action={save}>
<MandooEditor name="content" outputFormat="markdown" />
<button type="submit">Save</button>
</form>// useState
const [content, setContent] = useState('');
<MandooEditor onChange={setContent} outputFormat="html" />
// react-hook-form
const { setValue } = useForm();
<MandooEditor onChange={(v) => setValue('content', v)} outputFormat="markdown" />
// Zustand / Redux
<MandooEditor onChange={(v) => dispatch(setContent(v))} />Disable any toolbar button by setting its flag to false:
<MandooEditor
features={{
// Disable specific buttons
strikethrough: false,
align: false,
charMap: false,
help: false,
// All others remain enabled
}}
/>Full list of flags: bold, italic, strikethrough, lists, blockquote, hr, align, link, fullscreen, kitchenSink, underline, justify, foreColor, pasteAsText, removeFormat, charMap, indent, undo, help, media, subscript, superscript
<MandooEditor
plugins={{
linkChecker: true, // Validate URLs when inserting links
spellChecker: true, // Browser-native spell check
tables: true, // Insert & edit tables
imageEditor: true, // Crop/resize images before upload
history: true, // Edit history with restore
youtube: true, // Embed YouTube videos by URL
}}
/>Wire any storage backend — S3, MinIO, Cloudflare R2, or your own API:
<MandooEditor
media={{
accept: "image/*,video/*",
maxSize: 10 * 1024 * 1024, // 10 MB
async onUpload(file) {
const fd = new FormData();
fd.append("file", file);
const res = await fetch("/api/upload", { method: "POST", body: fd });
return res.json(); // { url: string, name?: string, alt?: string }
},
async onListFiles() {
const res = await fetch("/api/media");
return res.json(); // MediaFile[]
},
}}
/>###inIO / S3 Server Route (Next.js App Router)
// app/api/upload/route.ts
import { Client } from "minio"; // npm install minio
import { NextRequest, NextResponse } from "next/server";
const minio = new Client({
endPoint: process.env.MINIO_ENDPOINT!,
useSSL: true,
accessKey: process.env.MINIO_ACCESS_KEY!,
secretKey: process.env.MINIO_SECRET_KEY!,
});
export async function POST(req: NextRequest) {
const form = await req.formData();
const file = form.get("file") as File;
const buf = Buffer.from(await file.arrayBuffer());
const name = `uploads/${Date.now()}-${file.name}`;
await minio.putObject(process.env.MINIO_BUCKET!, name, buf, buf.length, {
"Content-Type": file.type,
});
const url = await minio.presignedGetObject(
process.env.MINIO_BUCKET!,
name,
604800
);
return NextResponse.json({ url, name: file.name });
}// HTML output (default)
<MandooEditor
outputFormat="html"
onChange={(html) => {
// "<p>Hello <strong>world</strong></p>"
console.log(html);
}}
/>
// Markdown output
<MandooEditor
outputFormat="markdown"
onChange={(md) => {
// "Hello **world**"
console.log(md);
}}
/>// Only show Visual and Text tabs (no Block editor)
<MandooEditor tabs={['visual', 'text']} />
// Start on Block tab
<MandooEditor defaultTab="block" />
// Only Block editor
<MandooEditor tabs={['block']} />The following features require an apiToken and will be available in a future paid tier:
- Export to PDF — one-click export via Mandoo cloud API
- Word Import/Export — read and write
.docxfiles - AI Assistant — chat with AI to rewrite, summarise, or extend content
// Reserve your token now — setting it has no effect until pro plugins are released
<MandooEditor apiToken="mk_live_..." />import { mandooFetch, validateToken } from "mandoo-editor";
// Validate a token format
const valid = validateToken("mk_live_abc123...");
// Call Mandoo API (for pro features)
const result = await mandooFetch(
"/export/pdf",
{ method: "POST", body: fd },
{
token: "mk_live_...",
baseUrl: "https://api.mandooeditor.com/v1", // optional override
}
);import type {
MandooEditorProps,
MandooEditorHandle,
Features,
Plugins,
MediaConfig,
MediaFile,
MediaUploadResult,
TabId,
OutputFormat,
TokenConfig,
} from "mandoo-editor";| 🌍 Website | mandooeditor.markrahimi.com |
| 📦 npm | npmjs.com/package/mandoo-editor |
| 🐙 GitHub | github.com/markrahimi/mandoo-editor |
| 🐛 Issues | github.com/markrahimi/mandoo-editor/issues |
| ☕ Support | ko-fi.com/markrahimi |
| 👤 Author | markrahimi.com |
MIT © Mohammad Ali Rahimi