react-email-rte
v0.1.0
Published
A React rich-text editor built on TipTap v3, purpose-built for composing HTML emails. Preserves inline styles, the full document shell, and produces email-safe (bulletproof) markup.
Maintainers
Readme
react-email-rte
A React rich-text editor built on TipTap v3, purpose-built for composing HTML emails.
Editing email HTML is not like editing article HTML. This package is designed around three constraints that ordinary rich-text editors get wrong:
- Emails are full documents —
<!doctype html><html><body style="…"><table>…. AcontentEditable/ProseMirror surface cannot host<html>/<head>/<body>; it silently strips them. So this editor edits only the body fragment and preserves the surrounding document shell verbatim (the document-shell bridge). - Emails rely on inline styles —
style="padding:…;background:…"on buttons, colored headings, table layouts. TipTap strips thestyleattribute by default. The bundledPreserveStylesextension keeps it. - Email clients need bulletproof markup — CTA buttons are
<a style="display:inline-block;…">wrapped in an aligned<p>, not<button>.
- ✅ Ships ESM + CJS + type declarations
- ✅ Works in Next.js App Router and plain Vite/CRA (
immediatelyRender:falseset internally for SSR) - ✅ No Tailwind/shadcn requirement — plain CSS you import
- ✅ TypeScript strict, no
anyin public types
Install
npm i react-email-rte
# peer deps (you already have these in a React app):
npm i react react-domimport { EmailEditor } from "react-email-rte";
import "react-email-rte/styles.css"; // requiredUsage
<EmailEditor> — batteries included (default export)
Handles the document-shell bridge, placeholder chips, and image upload wiring. value may be a full <!doctype><html><body>… document or a bare fragment; onChange always emits in the same shape it received.
import { useState } from "react";
import { EmailEditor } from "react-email-rte";
import "react-email-rte/styles.css";
export default function Composer() {
const [html, setHtml] = useState(
"<html><body><h1>Hi {{FirstName}}</h1></body></html>",
);
return (
<EmailEditor
value={html}
onChange={setHtml}
placeholders={[
{ token: "{{FirstName}}", label: "First name" },
{ token: "{{Email}}", label: "Email" },
]}
onUploadImage={async (file) => {
// upload `file` somewhere and return a hosted URL
return "https://cdn.example.com/x.png";
}}
minHeight={288}
/>
);
}<RichTextEditor> — the generic body-HTML editor (named export)
The toolbar + editable surface operating on a plain HTML fragment (no <html>/<body>). EmailEditor is a thin wrapper that adds the shell bridge + chips around this. Use it if you want to build your own wrapper.
import { useRef, useState } from "react";
import { RichTextEditor, type RichTextEditorHandle } from "react-email-rte";
import "react-email-rte/styles.css";
function Body() {
const [body, setBody] = useState("<p>Body HTML only.</p>");
const ref = useRef<RichTextEditorHandle>(null);
return (
<>
<button onClick={() => ref.current?.insertAtCaret("{{FirstName}}")}>
Insert token
</button>
<RichTextEditor
ref={ref}
value={body}
onChange={setBody}
editorStyle={{ minHeight: 240 }}
toolbar={{ button: false }} // hide the CTA-button control
/>
</>
);
}Props
EmailEditorProps
| Prop | Type | Default | Description |
| --------------- | ----------------------------------------- | -------------- | ----------- |
| value | string | — | Full HTML document or bare body fragment. |
| onChange | (value: string) => void | — | Fires with HTML in the same shape as value (shell re-applied). |
| placeholders | EmailPlaceholder[] | undefined | Merge tokens → renders a chip row that inserts at the caret. |
| onUploadImage | (file: File) => Promise<string> | undefined | Resolve an upload to a URL. If omitted, the image button prompts for a URL. |
| minHeight | number | 288 | Min height (px) of the editable surface. |
| placeholder | string | "Write your email…" | Empty-state text. |
| toolbar | ToolbarConfig | all on | Which controls to render. |
| className | string | undefined | Extra class on the wrapper. |
RichTextEditorProps
| Prop | Type | Default | Description |
| --------------- | ----------------------------------- | ------- | ----------- |
| value | string | — | Body-level HTML fragment. |
| onChange | (value: string) => void | — | Fires with the body-level HTML fragment. |
| placeholder | string | "Write your email…" | Empty-state text. |
| onUploadImage | (file: File) => Promise<string> | undefined | Upload handler (else URL prompt). |
| className | string | undefined | Extra class on the wrapper. |
| editorStyle | React.CSSProperties | undefined | Applied to the editable surface (e.g. { minHeight }). |
| toolbar | ToolbarConfig | all on | Which controls to render. |
RichTextEditor is a forwardRef exposing RichTextEditorHandle:
type RichTextEditorHandle = { insertAtCaret: (htmlOrText: string) => void };ToolbarConfig
Every key defaults to true. Set one to false to hide that control/group.
type ToolbarConfig = {
inline?: boolean; // Bold / Italic / Underline / Strikethrough / Code
headings?: boolean; // H2–H6
lists?: boolean; // Bullet / Ordered / Quote
align?: boolean; // Left / Center / Right
link?: boolean;
image?: boolean;
button?: boolean; // Email CTA button dialog
blocks?: boolean; // Paragraph / Divider / Footer presets
html?: boolean; // Raw HTML source toggle
};Building blocks (also exported)
For advanced use, the underlying pieces are exported:
import {
PreserveStyles, // the TipTap extension that keeps inline `style`
createEmailExtensions, // the full extension set as a factory
buildButtonHtml, // (config: EmailButtonConfig) => string
EmailButtonDialog, // the button-config modal
splitEmailHtml, // (html) => { prefix, body, suffix }
joinEmailHtml, // (shell, body) => html
extractEmailBody, // (html) => body fragment
escapeHtml,
presets, // { paragraph, divider, footer, heading }
} from "react-email-rte";
import type {
EmailButtonConfig,
EmailPlaceholder,
ToolbarConfig,
EmailHtmlDocument,
} from "react-email-rte";buildButtonHtml
const html = buildButtonHtml({
text: "Get started",
href: "https://example.com",
bgColor: "#2563eb",
textColor: "#ffffff",
align: "center",
radius: 6,
fullWidth: false,
});
// → <p style="margin:16px 0;text-align:center"><a href="…" style="display:inline-block;…">Get started</a></p>The anchor is wrapped in a <p> (not a <div>) on purpose: TipTap has no div node and would unwrap a <div>, losing the alignment. A paragraph's text-align is preserved by the TextAlign extension.
Theming
styles.css targets .rte-* classes and the .ProseMirror/.rte-content surface, and exposes CSS custom properties with neutral defaults. Override any of them on a parent element (or :root):
.my-editor {
--rte-accent: #7c3aed;
--rte-accent-contrast: #ffffff;
--rte-border: #e2e8f0;
--rte-border-strong: #cbd5e1;
--rte-bg: #ffffff;
--rte-bg-muted: #f8fafc;
--rte-fg: #0f172a;
--rte-fg-muted: #64748b;
--rte-radius: 10px;
--rte-active-bg: #f5f3ff;
}<EmailEditor className="my-editor" value={html} onChange={setHtml} />SSR / Next.js App Router
- The component entry is marked
"use client", so importing it from a Server Component is fine — render<EmailEditor>inside a client boundary. immediatelyRender: falseis set internally, which prevents the hydration mismatch TipTap otherwise throws under SSR. You don't need to configure anything.
Built-in extensions
The editor is configured with TipTap v3:
StarterKit(which already bundles Link, Underline, lists, blockquote, code, and headings — do not add@tiptap/extension-linkor@tiptap/extension-underlineyourself), with Link configuredopenOnClick:false,autolink:true,rel="noopener noreferrer".@tiptap/extension-image(inline:false,allowBase64:false)@tiptap/extension-text-align(["heading","paragraph"])@tiptap/extension-text-style(TextStyle+Color, re-exported here — no separate@tiptap/extension-colorneeded)@tiptap/extension-highlight(multicolor:true)@tiptap/extension-placeholder(StarterKit v3 does not bundle it; added explicitly for the empty-state hint, which sets theis-editor-empty/is-emptyclass anddata-placeholderattribute thatstyles.cssrenders)PreserveStyles(this package)
License
MIT
