email-builder-standalone
v4.1.2
Published
A React email builder component with drag and drop functionality
Maintainers
Readme
email-builder-online
Powerful, modern email builder with drag-and-drop blocks, live preview, and HTML export. Built with React and Material UI, distributed as a React component and as a Web Component so it can be embedded in other frameworks (Nuxt 3, Next.js, SvelteKit, etc.). Compatible with React 18 and 19.
Features
- Drag-and-drop blocks: Text (Rich Text Editor), Image, Button, Columns, Divider, Spacer, Social Media, Container
- Editor and Preview tabs with responsive screen sizes (Desktop/Mobile)
- Undo/Redo and keyboard shortcuts
- HTML export and copy-to-clipboard helpers
- CSS size guard for email client compatibility
- Dark mode support
- AI features for text (rewrite, grammar, continue, tone) and image generation
- Image gallery integration with custom image provider support
- Internationalization (i18n) with English, Spanish, and Italian support
- Works as React component or Web Component - embed in any framework (Nuxt 3, Next.js, SvelteKit, Vue, etc.)
- Built with TypeScript for better developer experience
- Responsive design that works on mobile and desktop
Installation
Install the package along with React:
npm i email-builder-online react react-dom
# or
yarn add email-builder-online react react-dom
# or
pnpm add email-builder-online react react-domNote: All other dependencies (Material UI, i18n, drag-and-drop, Tiptap, etc.) are bundled with the package — no extra installs needed.
Requirements: This package targets Node 24+ and pnpm (declared in
engines). Node 24 + pnpm are required when building or self-hosting the builder from source.
Peer dependencies (React / React-DOM)
react and react-dom must be the same major/minor version (e.g. both 18.x or both 19.x). Mixing versions (e.g. react@18 with react-dom@19) can cause runtime errors such as "Cannot read properties of undefined (reading 'S')".
If your project uses Vite, add this to your vite.config to avoid multiple instances of React (recommended on Windows and in monorepos):
export default defineConfig({
resolve: {
dedupe: ['react', 'react-dom', 'react-dom/client'],
},
// ...
});Add the stylesheet (required):
// React / Vite / Nuxt / Next
import 'email-builder-online/style.css';AI Features
The builder supports AI-powered features for both text and image blocks, controlled by a single enableAI prop.
Text AI
When enableAI is true, the rich text editor (NotionText) shows AI actions in the bubble menu toolbar and slash menu:
- Rewrite, grammar check, continue writing
- Tone adjustments: shorter, descriptive, detailed, friendly, professional
Text AI uses the onAIRequest callback to process requests. You provide the backend integration:
<EmailBuilder
enableAI={true}
onAIRequest={async ({ text, content, action, blockId }) => {
const response = await fetch('/api/ai', {
method: 'POST',
body: JSON.stringify({ text, content, action }),
headers: { 'Content-Type': 'application/json' },
});
const data = await response.json();
return data.processedContent;
}}
/>Image AI Generation
When enableAI is true, image blocks and background image inputs show a "Generate with AI" button that opens a dialog for prompt-based image generation.
Events
The image AI generation uses a custom event system:
request-ai-image - Fired when the user submits a prompt to generate an image
window.addEventListener('request-ai-image', (event: CustomEvent) => {
const prompt = event.detail; // string: the user's prompt
// Call your AI image generation API
const imageUrl = await generateImage(prompt);
// Respond with the generated image
window.dispatchEvent(
new CustomEvent('generated-image', {
detail: { url: imageUrl, success: true },
})
);
});store-ai-image - Fired when the user confirms and inserts the generated image
window.addEventListener('store-ai-image', (event: CustomEvent) => {
const imageUrl = event.detail; // string: URL of the image to store
// Persist the image to your storage if needed
});generated-image - Dispatch this event to return the generated image to the builder
// Success
window.dispatchEvent(
new CustomEvent('generated-image', {
detail: { url: 'https://example.com/generated.png', success: true },
})
);
// Error
window.dispatchEvent(
new CustomEvent('generated-image', {
detail: { url: null, success: false, error: { code: 500, message: 'Generation failed' } },
})
);Template AI Generation (demo)
The dev playground wires onAIGenerateTemplate to the bundled
@eb/backend workspace package so you can try the
end-to-end flow (prompt → SSE NDJSON stream → live preview → Apply).
Run both packages in parallel from the repo root (two terminals):
# Terminal 1 — AI backend on http://localhost:3100
cp packages/backend/.env.example packages/backend/.env
# edit OPENAI_API_KEY inside packages/backend/.env
pnpm dev:backend
# Terminal 2 — React 19 playground on http://localhost:2501
pnpm devThe consumer code in playground/react-19/src/App.tsx is the minimal
reference: a single fetch to http://localhost:3100/api/generate that
returns the SSE response body as a ReadableStream<string> to the
builder's dialog. The backend URL is hardcoded there — change the
AI_BACKEND_URL constant if your backend runs elsewhere.
Custom Image Provider
You can integrate your own image selector/gallery by passing a React component through the customImageProvider prop. This component will be rendered at the top of the image input panel, allowing users to select images from your custom source.
Events
The custom image provider can listen to and dispatch the following events:
Listening to Events
email-builder-image-panel-opened - Fired when an Image block is selected/opened in the editor
window.addEventListener('email-builder-image-panel-opened', (event: CustomEvent) => {
const { blockId, currentImageUrl, alt } = event.detail;
// You can use this to highlight the current image in your gallery
// or load additional data based on the current selection
});Event detail properties:
blockId(string): The ID of the selected image blockcurrentImageUrl(string | null): The URL of the currently selected image, or null if no image is setalt(string | null): The alt text of the current image, or null if not set
Dispatching Events
email-builder-set-image - Dispatch this event to set an image in the currently selected block
window.dispatchEvent(
new CustomEvent('email-builder-set-image', {
detail: imageUrl, // string: URL of the image to set
})
);Complete Example
import React, { useEffect, useState } from 'react';
import { EmailBuilder } from 'email-builder-online';
function MyImageGallery() {
const [currentImageUrl, setCurrentImageUrl] = useState<string | null>(null);
useEffect(() => {
// Listen for when the image panel opens
const handlePanelOpened = (event: CustomEvent) => {
const { currentImageUrl } = event.detail;
setCurrentImageUrl(currentImageUrl);
};
window.addEventListener('email-builder-image-panel-opened', handlePanelOpened);
return () => {
window.removeEventListener('email-builder-image-panel-opened', handlePanelOpened);
};
}, []);
const handleImageSelect = (imageUrl: string) => {
// Dispatch event to set the image
window.dispatchEvent(
new CustomEvent('email-builder-set-image', {
detail: imageUrl,
})
);
};
return (
<div>
<h4>My Custom Gallery</h4>
{currentImageUrl && <p style={{ fontSize: '12px', color: '#666' }}>Current: {currentImageUrl}</p>}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '8px' }}>
<img
src="https://example.com/image1.jpg"
onClick={() => handleImageSelect('https://example.com/image1.jpg')}
style={{
cursor: 'pointer',
width: '100%',
border: currentImageUrl === 'https://example.com/image1.jpg' ? '2px solid blue' : 'none',
}}
/>
{/* More images... */}
</div>
</div>
);
}
export default function Page() {
return (
<EmailBuilder
customImageProvider={<MyImageGallery />}
// ... other props
/>
);
}Live Example: Check out the working implementation in
packages/email-builder-online/src/components/SampleImageGallery.tsxand see it in action in the demo app.
Custom Merge Tags
You can provide your own merge tags that will appear in the text editor's merge tag menu. Pass an array of tags or a complete group configuration:
Simple Array of Tags
import { EmailBuilder, MergeTag } from 'email-builder-online';
const myMergeTags: MergeTag[] = [
{
label: 'First Name',
value: '[first_name]',
},
{
label: 'Last Name',
value: '[last_name]',
},
{
label: 'Company',
value: '[company]',
},
{
type: 'divider', // Add a divider
},
{
label: 'Custom Link',
value: '{custom_link}Click here{/custom_link}',
},
];
export default function Page() {
return (
<EmailBuilder
mergeTags={myMergeTags}
// ... other props
/>
);
}Complete Group with Custom Icons
import { EmailBuilder, MergeTagGroup } from 'email-builder-online';
import { User, Building, Mail } from 'lucide-react'; // or any icon library
const myMergeTagGroup: MergeTagGroup = {
label: 'My Custom Tags',
icon: <Mail />,
children: [
{
label: 'First Name',
value: '[first_name]',
icon: <User />,
},
{
label: 'Company Name',
value: '[company]',
icon: <Building />,
},
{
type: 'divider',
},
{
label: 'Verification Link',
value: '{verify}Verify Account{/verify}',
icon: <Mail />,
},
],
};
export default function Page() {
return (
<EmailBuilder
mergeTags={myMergeTagGroup}
// ... other props
/>
);
}Programmatic Image Loading
You can load images programmatically using the ref:
import { useRef } from 'react';
import { EmailBuilder, EmailBuilderRef } from 'email-builder-online';
export default function Page() {
const emailBuilderRef = useRef<EmailBuilderRef>(null);
const handleSelectFromMyGallery = (imageUrl: string, blockId: string) => {
// Set image URL for a specific block
emailBuilderRef.current?.setImageUrl(blockId, imageUrl);
};
return (
<EmailBuilder
ref={emailBuilderRef}
customImageProvider={<MyCustomGallery onSelect={handleSelectFromMyGallery} />}
/>
);
}Using with Custom Image Provider Events
You can combine the ref approach with the event system for a more robust solution:
import { useRef, useEffect, useState } from 'react';
import { EmailBuilder, EmailBuilderRef } from 'email-builder-online';
function MyCustomGallery() {
const [currentBlockId, setCurrentBlockId] = useState<string | null>(null);
const [currentImageUrl, setCurrentImageUrl] = useState<string | null>(null);
useEffect(() => {
const handlePanelOpened = (event: CustomEvent) => {
const { blockId, currentImageUrl } = event.detail;
setCurrentBlockId(blockId);
setCurrentImageUrl(currentImageUrl);
};
window.addEventListener('email-builder-image-panel-opened', handlePanelOpened);
return () => window.removeEventListener('email-builder-image-panel-opened', handlePanelOpened);
}, []);
const handleImageSelect = (imageUrl: string) => {
// Use the event system to set the image
window.dispatchEvent(
new CustomEvent('email-builder-set-image', {
detail: imageUrl,
})
);
};
return (
<div>
<h4>Select Image</h4>
{currentImageUrl && <p>Current: {currentImageUrl}</p>}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '8px' }}>
{/* Your image gallery */}
</div>
</div>
);
}
export default function Page() {
const emailBuilderRef = useRef<EmailBuilderRef>(null);
// Alternative: Use ref method directly
const handleSelectFromGallery = (imageUrl: string, blockId: string) => {
emailBuilderRef.current?.setImageUrl(blockId, imageUrl);
};
return <EmailBuilder ref={emailBuilderRef} customImageProvider={<MyCustomGallery />} />;
}Available Blocks
The email builder includes the following drag-and-drop blocks:
- Notion Text - Rich text editor with Notion-like editing experience (Tiptap-based)
- Image - Image block with link support
- Button - Call-to-action button with customizable styling
- Columns - Multi-column layout container
- Divider - Horizontal divider/separator line
- Spacer - Vertical spacing block
- Social Media - Social media icons with links
- Container - Container block for grouping content
Backward Compatibility
The email builder automatically migrates legacy blocks when loading a document so templates created before the NotionText block was introduced still hydrate cleanly. The following retired block types are converted to NotionText on load:
- CustomEditor → NotionText (type rename, compatible data)
- Html → NotionText (
props.contentswrapped intoprops.html) - Heading → NotionText (
props.textwrapped into<hN>…</hN>inprops.html)
Other previously-supported types (Wysiwyg, Text, Avatar) were fully retired and are no longer migrated — old documents containing them will silently drop those blocks.
Usage
There are three ways to use the builder:
1) As a React component with Ref (recommended for programmatic control)
Use a ref to control saving and access the document programmatically:
import React, { useRef, useState, useEffect } from 'react';
import { EmailBuilder, EmailBuilderRef, TEditorConfiguration, MergeTag } from 'email-builder-online';
import 'email-builder-online/style.css';
// Custom Image Gallery Component
function MyImageGallery() {
const images = ['https://example.com/image1.jpg', 'https://example.com/image2.jpg', 'https://example.com/image3.jpg'];
const handleImageSelect = (imageUrl: string) => {
window.dispatchEvent(
new CustomEvent('email-builder-set-image', {
detail: imageUrl,
})
);
};
return (
<div>
<h4>My Custom Gallery</h4>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '8px' }}>
{images.map((img, idx) => (
<img
key={idx}
src={img}
onClick={() => handleImageSelect(img)}
style={{ cursor: 'pointer', width: '100%', borderRadius: '4px' }}
/>
))}
</div>
</div>
);
}
// Custom Merge Tags
const customMergeTags: MergeTag[] = [
{
label: 'First Name',
value: '[first_name]',
},
{
label: 'Last Name',
value: '[last_name]',
},
{
label: 'Company',
value: '[company]',
},
{
type: 'divider',
},
{
label: 'Verification Link',
value: '{verify}Verify your account{/verify}',
},
];
export default function Page() {
const emailBuilderRef = useRef<EmailBuilderRef>(null);
const [document, setDocument] = useState<TEditorConfiguration | null>(null);
// Load saved document on mount
useEffect(() => {
const saved = localStorage.getItem('draft');
if (saved) {
setDocument(JSON.parse(saved));
}
}, []);
const handleSave = () => {
if (emailBuilderRef.current) {
const document = emailBuilderRef.current.save();
// Send to your backend
fetch('/api/save', {
method: 'POST',
body: JSON.stringify(document),
headers: { 'Content-Type': 'application/json' },
});
}
};
const handleExportHtml = () => {
if (emailBuilderRef.current) {
const html = emailBuilderRef.current.getHtml();
// Download HTML file or send to backend
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();
URL.revokeObjectURL(url);
}
};
const handleSendEmail = async () => {
if (emailBuilderRef.current) {
const html = emailBuilderRef.current.getHtml();
// Send email via your backend
await fetch('/api/send-email', {
method: 'POST',
body: JSON.stringify({ html }),
headers: { 'Content-Type': 'application/json' },
});
}
};
return (
<div style={{ height: '100vh' }}>
<button onClick={handleSave}>Save</button>
<button onClick={handleExportHtml}>Export HTML</button>
<button onClick={handleSendEmail}>Send Email</button>
<EmailBuilder
ref={emailBuilderRef}
data={document}
onAutoSave={(doc) => localStorage.setItem('draft', JSON.stringify(doc))}
customImageProvider={<MyImageGallery />}
mergeTags={customMergeTags}
primaryColor="#0d9488"
secondaryColor="#0ea5a6"
locale="en"
height="calc(100vh - 80px)"
/>
</div>
);
}Ref Methods:
The EmailBuilderRef exposes the following methods:
getDocument(): TEditorConfiguration- Returns the current editor documentsetDocument(document: TEditorConfiguration): void- Replaces the current documentsave(): TEditorConfiguration- Flushes pending changes, callsonSave(if provided) and returns the documentgetHtml(): string- Returns the rendered HTML for the current documentsetImageUrl(blockId: string, url: string): void- Sets the URL of an image block with the given ID and updates the document
2) As a React component (basic usage)
import React from 'react';
import { EmailBuilder } from 'email-builder-online';
import 'email-builder-online/style.css';
export default function Page() {
return (
<div style={{ height: '100vh' }}>
<EmailBuilder
primaryColor="#0d9488"
secondaryColor="#0ea5a6"
darkMode={false}
stickyHeader
locale="en"
height="calc(100vh - 80px)"
/>
</div>
);
}3) As a Web Component (works in Nuxt 3, Vue, Svelte, plain HTML)
For Web Component usage, the component auto-registers in browser environments. Here's how to use it in different frameworks:
Nuxt 3 Example
- Register the email builder component:
// plugins/email-builder.client.ts
import { registerEmailBuilder } from 'email-builder-online';
import 'email-builder-online/style.css';
export default defineNuxtPlugin(() => {
// Register the web component
registerEmailBuilder('email-builder');
});Vanilla JavaScript Example
For plain HTML (no bundler, no React in the host page), use the standalone build.
It bundles React + ReactDOM, renders inside a Shadow DOM, and auto-registers the
<email-builder> element on load (no manual registerEmailBuilder call needed).
The standalone files (
dist/standalone.js,dist/standalone.mjs) are produced bypnpm build:standalone(orpnpm build:all). The regulardist/index.*build externalizes React and is meant for bundler-based apps, not plain<script>usage.
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="path/to/email-builder-online/dist/style.css" />
<!-- IIFE build (no module support needed); exposes window.EmailBuilder -->
<script src="path/to/email-builder-online/dist/standalone.js"></script>
</head>
<body>
<!-- Auto-registered on load -->
<email-builder></email-builder>
</body>
</html>Or with the ES module variant:
<link rel="stylesheet" href="path/to/email-builder-online/dist/style.css" />
<script type="module" src="path/to/email-builder-online/dist/standalone.mjs"></script>
<email-builder></email-builder>Then use it anywhere in your templates (wrap in <ClientOnly> for Nuxt):
<template>
<ClientOnly>
<email-builder
primary-color="#0d9488"
secondary-color="#0ea5a6"
dark-mode="false"
sticky-header="true"
locale="en"
height="calc(100vh - 80px)"
/>
</ClientOnly>
</template>For Vue/Nuxt, you can silence unknown element warnings by marking the tag as a custom element:
// nuxt.config.ts
export default defineNuxtConfig({
vue: {
compilerOptions: {
isCustomElement: (tag) => tag === 'email-builder',
},
},
css: ['email-builder-online/style.css'],
});Notes on SSR and dependencies:
- The Web Component wrapper is registered only on the client. Use a client-only plugin in SSR frameworks.
- React and ReactDOM are peer dependencies for the package-based usage (React component and the
registerEmailBuilderWeb Component). Install them in your app or use a bundler that provides them. React 18 and 19 are supported. (The self-containedstandalonebuild bundles its own React.) - i18n dependencies (
i18next,react-i18next,i18next-browser-languagedetector) are bundled with the package — you do not need to install them separately.
Props / Attributes
React Props (camelCase) and Web Component attributes (dash-case) map 1:1:
| React Prop | Web Component Attribute | Type | Default | Description |
| ------------------- | ----------------------- | ---------------------------------------- | ------- | ---------------------------------------------------------------------------------------- |
| ref | - | React.Ref | - | Ref to access component methods (getDocument, setDocument, save, getHtml, setImageUrl) |
| onSave | - | (document: TEditorConfiguration) => void | - | Callback when ref.save() is called |
| onAutoSave | - | (document: TEditorConfiguration) => void | - | Callback for auto-save (2s after changes) |
| data | data | TEditorConfiguration | string | - | Document to load (reactive - updates when changed) |
| initialDocument | - | TEditorConfiguration | string | - | Initial document to load on mount (one-time only) |
| customImageProvider | - | React.ReactNode | - | Custom image selector component to integrate your own image gallery |
| mergeTags | - | MergeTag[] | MergeTagGroup | - | Custom merge tags to show in the text editor |
| primaryColor | primary-color | string | #058705 | Primary theme color |
| secondaryColor | secondary-color | string | #079707 | Secondary theme color |
| darkMode | dark-mode | boolean | false | Enable dark mode |
| height | height | string | - | Container height (e.g. "calc(100vh - 80px)") |
| stickyHeader | sticky-header | boolean | true | Sticky header behavior |
| sticky | sticky | boolean | false | Sticky content behavior |
| galleryImages | gallery-images | boolean | false | Enable image gallery |
| locale | locale | string | - | UI language (en, es, it, en-US, es-419, it-IT). Falls back to dataLocale if not provided |
| dataLocale | data-locale | string | - | Alternative locale prop (used as fallback if locale is not provided) |
| htmlTab | html-tab | boolean | true | Show HTML tab |
| jsonTab | json-tab | boolean | true | Show JSON tab |
| imagePlaceholder | image-placeholder | string | - | Default placeholder for images |
| imageUrlInput | image-url-input | boolean | true | Show the URL field in the Image block picker's Upload tab. When false, only the dropzone is rendered (subject to imageUploadInput). When both imageUrlInput and imageUploadInput are false, the Upload tab is hidden. |
| imageUploadInput | image-upload-input | boolean | true | Show the drag & drop / file upload zone in the Image block picker's Upload tab. When false, only the URL field is rendered (subject to imageUrlInput). When both imageUrlInput and imageUploadInput are false, the Upload tab is hidden. |
| backgroundUrlInput | background-url-input | boolean | true | Show the URL field in the Background image picker's Upload tab (Container, ColumnsContainer, EmailLayout). Same hide-on-both-false behavior as imageUrlInput. |
| backgroundUploadInput | background-upload-input | boolean | true | Show the drag & drop / file upload zone in the Background image picker's Upload tab. Same hide-on-both-false behavior as imageUploadInput. |
| enableAI | enable-ai | boolean | false | Enable AI features for text and image generation |
| onAIRequest | - | (request: AIFeatureRequest) => Promise<string> | - | Callback for AI text processing requests |
| onAIGenerateTemplate | - | (request: AIGenerateTemplateRequest, options: { signal: AbortSignal }) => Promise<AIGenerateTemplateResponse> | - | Callback for AI template generation (prompt → template). When undefined, the "Generate with AI" entry point is hidden |
| unsplashEnabled | - | boolean | false | Show the built-in Unsplash picker tab (requires the backend proxy) |
| unsplashBackendUrl | - | string | - | Override the backend URL used for Unsplash proxy calls |
| portalContainer | - | HTMLElement | - | Container element for MUI portals (menus, dialogs); useful when mounting inside Shadow DOM |
| showVersion | show-version | boolean | - | Show version indicator in the editor |
| componentTree | component-tree | boolean | true | Show the component tree panel |
Internationalization (i18n)
The builder supports multiple languages. Pass the locale prop with one of the supported values:
enoren-US- English (default)esores-419- Spanishitorit-IT- Italian
<EmailBuilder locale="es" />TypeScript
Types are shipped. You can import them as:
import type { EmailBuilderProps, AIFeatureRequest, MergeTag, MergeTagGroup, EmailBuilderRef } from 'email-builder-online';The AIFeatureRequest interface:
interface AIFeatureRequest {
text: string; // Selected or relevant text
content: string; // Full block content
action: string; // AI action (rewrite, grammar_check, continue_writing, shorter, descriptive, detailed, friendly, professional)
blockId?: string; // Block ID where the request originated
}License
Free to use. © Laravel42. All rights reserved.
Links
- 🌐 Website: laravel42.com
- 📦 More Packages: npmjs.com/~laravel42
- 💼 LinkedIn: Laravel42
- 📘 Facebook: Laravel42
- 📸 Instagram: @laravel42_
Changelog
See Git history for details. Please open issues or PRs for bugs and improvements.
