The fully AI-powered React editor for email templates and landing pages. Your end-users design visually with drag-and-drop — or describe what they want and watch AI agents build it live on the canvas. Powered by the built-in Model Context Protocol (MCP) server, connect Claude Code, OpenCode, Codex, Cursor, or your own AI backend directly to the editor. Structured tool calls mean guaranteed-valid output — no prompt engineering, no JSON hallucination, no broken layouts.
Dragble brings two design experiences together in one React component: a polished visual editor for designers and a conversational AI surface for everyone else — backed by structured tool calls that produce guaranteed-valid HTML emails and landing pages every time.
Website | Documentation | Dashboard
- Drag-and-drop email template builder with 20+ content blocks
- Fully AI-powered via MCP — connect AI agents (Claude Code, OpenCode, Codex, Cursor) or your own AI backend to build designs live on the canvas. Structured tool calls mean guaranteed-valid output — no prompt engineering, no JSON hallucination
- Responsive HTML email output compatible with all major email clients
- Newsletter editor with merge tags, dynamic content, and display conditions
- Visual email designer — no HTML/CSS knowledge required for end users
- Export to HTML, JSON, image, PDF, or ZIP
- Built-in image editor, AI content generation, and collaboration tools
- Full TypeScript support
- Lightweight React wrapper — just a single component or hook
# npm
npm install dragble-react-editor
# yarn
yarn add dragble-react-editor
# pnpm
pnpm add dragble-react-editorAn editorKey is required to use the editor. You can get one by creating a project on the Dragble Developer Dashboard.
import { useRef } from "react";
import {
DragbleEditor,
DragbleEditorRef,
DesignJson,
} from "dragble-react-editor";
function EmailBuilder() {
const editorRef = useRef<DragbleEditorRef>(null);
const handleChange = async (data: { design: DesignJson; type: string }) => {
// Design JSON is available directly from the callback
const json = data.design;
console.log("Design JSON:", json);
// To get HTML, call exportHtml on the editor
const html = await editorRef.current?.editor?.exportHtml();
console.log("HTML:", html);
};
return (
<div style={{ height: "100vh" }}>
<DragbleEditor
ref={editorRef}
editorKey="your-editor-key"
editorMode="email"
minHeight="600px"
onReady={(editor) => console.log("Editor ready!")}
onChange={handleChange}
onError={(error) => console.error("Editor error:", error)}
/>
</div>
);
}import { useRef, useState, useCallback } from "react";
import {
DragbleEditor,
DragbleEditorRef,
DesignJson,
} from "dragble-react-editor";
function AdvancedEmailBuilder() {
const editorRef = useRef<DragbleEditorRef>(null);
const [isDirty, setIsDirty] = useState(false);
const handleReady = useCallback((editor) => {
// Set merge tags (must pass a MergeTagsConfig object)
editor.setMergeTags({
customMergeTags: [
{ name: "First Name", value: "{{first_name}}" },
{ name: "Last Name", value: "{{last_name}}" },
{ name: "Company", value: "{{company}}" },
],
excludeDefaults: false,
sort: true,
});
// Set custom fonts
editor.setFonts({
showDefaultFonts: true,
customFonts: [{ label: "Brand Font", value: "BrandFont, sans-serif" }],
});
// Load saved design if available
const savedDesign = localStorage.getItem("email-design");
if (savedDesign) {
editor.loadDesign(JSON.parse(savedDesign));
}
}, []);
const handleChange = useCallback(
(data: { design: DesignJson; type: string }) => {
setIsDirty(true);
localStorage.setItem("email-design", JSON.stringify(data.design));
},
[],
);
const handleExportHtml = async () => {
const editor = editorRef.current?.editor;
if (!editor) return;
const html = await editor.exportHtml();
const blob = new Blob([html], { type: "text/html" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "email.html";
a.click();
};
const handleExportImage = async () => {
const editor = editorRef.current?.editor;
if (!editor) return;
const data = await editor.exportImage();
window.open(data.url, "_blank");
};
return (
<div style={{ height: "100vh", display: "flex", flexDirection: "column" }}>
<div
style={{
padding: 12,
borderBottom: "1px solid #ddd",
display: "flex",
gap: 8,
}}
>
<button onClick={() => editorRef.current?.editor?.undo()}>Undo</button>
<button onClick={() => editorRef.current?.editor?.redo()}>Redo</button>
<button
onClick={() => editorRef.current?.editor?.showPreview("desktop")}
>
Preview
</button>
<button onClick={handleExportHtml}>Export HTML</button>
<button onClick={handleExportImage}>Export Image</button>
{isDirty && <span style={{ color: "orange" }}>Unsaved changes</span>}
</div>
<DragbleEditor
ref={editorRef}
editorKey="your-editor-key"
editorMode="email"
height="100%"
designMode="live"
options={{
appearance: { theme: "light" },
features: {
preview: true,
undoRedo: true,
imageEditor: true,
},
}}
onReady={handleReady}
onChange={handleChange}
onError={(error) => console.error(error.message)}
/>
</div>
);
}
export default AdvancedEmailBuilder;Connect AI agents (Claude Code, OpenCode, Codex, Cursor, or your own backend) to the editor through the Model Context Protocol. The AI calls structured tools — add_row, add_heading, update_button, export_html — that mutate design state live on the canvas.
MCP is off by default. Set features: { mcp: true } to opt in:
<DragbleEditor
ref={editorRef}
editorKey="db_pxl81cxn92wignwx"
options={{ features: { mcp: true } }}
/>MCP also requires a Starter plan or higher. Both conditions must be true — plan allows it AND SDK enables it.
import { useRef } from "react";
import { DragbleEditor, DragbleEditorRef } from "dragble-react-editor";
function App() {
const editorRef = useRef<DragbleEditorRef>(null);
const handleConnectAI = async () => {
// The id is YOUR identifier — derive it from your own database/session
// so the same user editing the same document always gets the same MCP
// session. Example: if your logged-in user is "alice123" and they're
// editing document "campaign-summer-2026", build an id like this:
//
// const id = "alice123-campaign-summer-2026";
//
// Format rules: 8-128 chars, only letters/digits/hyphens/underscores.
const userIdFromAuth = "alice123"; // from your auth/session
const docIdFromRoute = "campaign-summer"; // from your URL or DB row
const id = `${userIdFromAuth}-${docIdFromRoute}`;
const { sessionId } = await editorRef.current!.editor!.connectMCP({ id });
// Pass sessionId to your backend — it calls MCP tools with your mcp_key
};
return (
<div style={{ height: "100vh" }}>
<button onClick={handleConnectAI}>Connect AI</button>
<DragbleEditor
ref={editorRef}
editorKey="db_pxl81cxn92wignwx"
options={{ features: { mcp: true } }}
/>
</div>
);
}const handleLetUserPair = async () => {
const editor = editorRef.current!.editor!;
// Same id you'd use anywhere else for this user+document combination.
// 8-128 chars, only letters/digits/hyphens/underscores.
const id = "alice123-campaign-summer-2026";
await editor.connectMCP({ id });
// Explicitly generate a pairing code (not auto-generated)
const { code, expiresAt } = await editor.getPairingCode();
alert(`Paste this into Claude Code: ${code}`);
};Each session can be controlled by either your backend or an end-user's AI client (OpenCode, Claude Code), never both at the same time:
- If your backend makes the first tool call → session is locked to backend. Pairing codes are rejected.
- If a user pairs via pairing code first → session is locked to paired client. Backend tool calls are rejected.
This prevents two AI controllers from conflicting on the same design.
- Enable MCP in the SDK config:
features: { mcp: true }. - Generate an MCP key in the Dragble dashboard: Project → MCP Key → Generate. Store it in your backend env vars — never in browser code.
- Call
editor.connectMCP({ id })whereidis a stable identifier you control (see below). - Choose your AI path: either your backend calls MCP tools directly (using the mcp_key), or you generate a pairing code for the end-user to connect their own AI client.
- Mutations stream live onto the editor canvas as the AI works.
The id you pass to connectMCP() is a Bring Your Own ID (BYOI) that maps to your domain entities. It is NOT a random token — it is how Dragble identifies the session across browser refreshes, server restarts, and device switches.
Rules:
- 8–128 characters long
- Only letters, numbers, hyphens, and underscores (
a-z A-Z 0-9 - _) - Must be deterministic — the same user editing the same document should always produce the same
id
Why these rules?
- The
idis used in database lookups and URL paths — special characters or extreme lengths would break routing - Same
id= resume the same session. Random UUIDs mean every page refresh creates a new session and loses AI context - Short IDs (< 8 chars) are too easy to guess, long IDs (> 128 chars) waste storage
// Recommended: derive from your domain — concrete examples
editor.connectMCP({ id: "alice123-campaign-summer-2026" }); // user + doc
editor.connectMCP({ id: "workspace_acme_template_welcome" }); // workspace + template
editor.connectMCP({ id: "org-uber-eats-promo-q4-2026" }); // org + campaign
editor.connectMCP({ id: "tenant_42_invoice_template_v3" }); // tenant + entity
// Valid but NOT recommended — random IDs break session continuity
// (every page refresh creates a brand new session, AI loses context)
editor.connectMCP({ id: crypto.randomUUID() });disconnectMCP() permanently destroys the session — the session cannot be reopened:
const { destroyed } = await editor.disconnectMCP();Your backend can also force-destroy a session server-side (e.g., when a user's subscription ends):
curl -X DELETE https://mcp.dragble.com/sessions/user-42-doc-99 \
-H "X-API-Key: db_mcp_your_key_here"Idle sessions are reaped after 2 hours of inactivity. Active sessions never expire — each tool call resets the timer.
| Method | Returns |
|---|---|
editor.connectMCP({ id, editorMode? }) |
{ sessionId, resumed? } |
editor.disconnectMCP() |
{ destroyed } — permanently deletes session |
editor.getPairingCode() |
{ code, expiresAt } — generate a pairing code for end-user AI clients |
editor.endPairing() |
{ revoked } — invalidate the active pairing code |
editor.getMCPStatus() |
{ paired: true, sessionId } | { paired: false, reason? } |
editor.onAIToolFired(cb) |
unsubscribe fn — fires when AI calls any tool |
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
editorKey |
string |
Yes | — | Editor key for authentication |
design |
DesignJson | ModuleData | null |
No | undefined |
Initial design to load |
editorMode |
EditorMode |
No | — | "email" | "web" | "popup" |
contentType |
"module" |
No | — | Single-row module editor mode |
options |
EditorOptions |
No | {} |
All editor configuration |
popup |
PopupConfig |
No | — | Popup config (only when editorMode is "popup") |
collaboration |
boolean | CollaborationFeaturesConfig |
No | — | Collaboration features |
user |
UserInfo |
No | — | User info for session/collaboration |
designMode |
"edit" | "live" |
No | "live" |
Template permissions mode |
height |
string | number |
No | — | Editor height |
minHeight |
string | number |
No | "600px" |
Minimum editor height |
callbacks |
Omit<DragbleCallbacks, "onReady" | "onLoad" | "onChange" | "onError"> |
No | — | SDK callbacks (excluding those handled by dedicated props) |
className |
string |
No | — | CSS class for the outer container |
style |
React.CSSProperties |
No | — | Inline styles for the outer container |
| onReady | (editor: DragbleSDK) => void | No | — | Called when the editor is ready |
| onLoad | () => void | No | — | Called when a design is loaded |
| onChange | (data: { design: DesignJson; type: string }) => void | No | — | Called when the design changes |
| onError | (error: Error) => void | No | — | Called on error |
| onComment | (action: CommentAction) => void | No | — | Called on comment events |
Use a ref to access the SDK instance:
const editorRef = useRef<DragbleEditorRef>(null);
// DragbleEditorRef shape:
// {
// editor: DragbleSDK | null;
// isReady: () => boolean;
// }The useDragbleEditor hook provides a convenient way to access the editor:
import { DragbleEditor, useDragbleEditor } from "dragble-react-editor";
function MyEditor() {
const { ref, editor, isReady } = useDragbleEditor();
return (
<div>
<button
onClick={async () => {
const html = await editor?.exportHtml();
console.log(html);
}}
disabled={!isReady}
>
Export
</button>
<DragbleEditor ref={ref} editorKey="your-editor-key" />
</div>
);
}Returns: { ref, editor, isReady } — ref is passed to the component, editor is the SDK instance (or null), and isReady is a boolean.
Access the SDK via editorRef.current?.editor or the editor value from useDragbleEditor(). All export and getter methods return Promises.
editor.loadDesign(design, options?); // void
const result = await editor.loadDesignAsync(design, options?);
// => { success, validRowsCount, invalidRowsCount, errors? }
editor.loadBlank(options?); // void
const { html, json } = await editor.getDesign(); // PromiseAll export methods are Promise-based. There are no callback overloads.
const html = await editor.exportHtml(options?); // Promise<string>
const json = await editor.exportJson(); // Promise<DesignJson>
const text = await editor.exportPlainText(); // Promise<string>
const imageData = await editor.exportImage(options?); // Promise<ExportImageData>
const pdfData = await editor.exportPdf(options?); // Promise<ExportPdfData>
const zipData = await editor.exportZip(options?); // Promise<ExportZipData>
const values = await editor.getPopupValues(); // Promise<PopupValues | null>setMergeTags accepts a MergeTagsConfig object, not a plain array.
editor.setMergeTags({
customMergeTags: [
{ name: "First Name", value: "{{first_name}}" },
{ name: "Company", value: "{{company}}" },
],
excludeDefaults: false,
sort: true,
});
const tags = await editor.getMergeTags(); // Promise<(MergeTag | MergeTagGroup)[]>setSpecialLinks accepts a SpecialLinksConfig object.
editor.setSpecialLinks({
customSpecialLinks: [{ name: "Unsubscribe", href: "{{unsubscribe_url}}" }],
excludeDefaults: false,
});
const links = await editor.getSpecialLinks(); // Promise<(SpecialLink | SpecialLinkGroup)[]>editor.setModules(modules); // void
editor.setModulesLoading(loading); // void
const modules = await editor.getModules(); // Promise<Module[]>editor.setFonts(config); // void
const fonts = await editor.getFonts(); // Promise<FontsConfig>editor.setBodyValues({
backgroundColor: "#f5f5f5",
contentWidth: "600px",
});
const values = await editor.getBodyValues(); // Promise<SetBodyValuesOptions>editor.setOptions(options); // void — Partial<EditorOptions>
editor.setToolsConfig(toolsConfig); // void
editor.setEditorMode(mode); // void
editor.setEditorConfig(config); // void
const config = await editor.getEditorConfig(); // Promise<EditorBehaviorConfig>editor.setLocale(locale, translations?); // void
editor.setLanguage(language); // void
const lang = await editor.getLanguage(); // Promise<Language | null>
editor.setTextDirection(direction); // void — 'ltr' | 'rtl'
const dir = await editor.getTextDirection(); // Promise<TextDirection>editor.setAppearance(appearance); // voideditor.undo(); // void
editor.redo(); // void
const canUndo = await editor.canUndo(); // Promise<boolean>
const canRedo = await editor.canRedo(); // Promise<boolean>
editor.save(); // voideditor.showPreview(device?); // void — 'desktop' | 'tablet' | 'mobile'
editor.hidePreview(); // voidawait editor.registerTool(config); // Promise<void>
await editor.unregisterTool(toolId); // Promise<void>
const tools = await editor.getTools(); // Promise<Array<{ id, label, baseToolType }>>await editor.createWidget(config); // Promise<void>
await editor.removeWidget(widgetName); // Promise<void>editor.showComment(commentId); // void
editor.openCommentPanel(rowId); // voideditor.updateTabs(tabs); // void
editor.setBrandingColors(config); // void
editor.registerColumns(cells); // voideditor.setDisplayConditions(config); // voidconst result = await editor.audit(options?); // Promise<AuditResult>const { success, url, error } = await editor.uploadImage(file, options?);
const { assets, total } = await editor.listAssets(options?);
const { success, error } = await editor.deleteAsset(assetId);
const folders = await editor.listAssetFolders(parentId?);
const folder = await editor.createAssetFolder(name, parentId?);
const info = await editor.getStorageInfo();editor.isReady(); // boolean
editor.destroy(); // voidSubscribe to editor events using addEventListener:
const unsubscribe = editor.addEventListener("design:updated", (data) => {
console.log("Design changed:", data);
});
// Or remove manually
editor.removeEventListener("design:updated", callback);| Event | Description |
|---|---|
editor:ready |
Editor initialized |
design:loaded |
Design loaded |
design:updated |
Design changed |
design:saved |
Design saved |
row:selected |
Row selected |
row:unselected |
Row unselected |
column:selected |
Column selected |
column:unselected |
Column unselected |
content:selected |
Content block selected |
content:unselected |
Content block unselected |
content:modified |
Content block modified |
content:added |
Content block added |
content:deleted |
Content block deleted |
preview:shown |
Preview opened |
preview:hidden |
Preview closed |
image:uploaded |
Image uploaded successfully |
image:error |
Image upload error |
export:html |
HTML exported |
export:plainText |
Plain text exported |
export:image |
Image exported |
save |
Save triggered |
save:success |
Save succeeded |
save:error |
Save failed |
template:requested |
Template requested |
element:selected |
Element selected |
element:deselected |
Element deselected |
export |
Export triggered |
displayCondition:applied |
Display condition applied |
displayCondition:removed |
Display condition removed |
displayCondition:updated |
Display condition updated |
All SDK types are re-exported from the package:
import type {
DragbleEditorRef,
DragbleEditorProps,
DesignJson,
EditorOptions,
MergeTag,
MergeTagGroup,
MergeTagsConfig,
SpecialLink,
SpecialLinkGroup,
SpecialLinksConfig,
FontsConfig,
EditorMode,
PopupConfig,
UserInfo,
CollaborationFeaturesConfig,
CommentAction,
} from "dragble-react-editor";See CONTRIBUTING.md for guidelines on how to contribute to this project.
