Quick start

The email template editor that lives in your app, not someone else's SaaS.

The email template editor that lives in your app, not someone else's SaaS.

Marketing emails are usually the one part of a product designed somewhere else, behind someone else's login, in someone else's brand tooling. This package puts the whole editing surface inside your own app: a grey canvas, a 600px card, and thirteen blocks the author drags into place. It is source-shipped like everything else here, so the editor is code you can read, rebrand and extend, and the document it produces is plain data your server renders into the sent email.

DesktopMobile
Your header

Not at allAbsolutely
The full footer (contact details, social links, unsubscribe) is added when the email is sent.

Block settings

Select a block to edit its settings.

This is the real component. Drag a block by its handle, edit the copy in place, or select a block and open its settings.

Install

bun add @voila.dev/ui @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities
globals.css
@import "@voila.dev/ui/styles/sources.css";

Or copy the source — it's the same files.

Peers: react@19, react-dom@19, tailwindcss@4.

The 3-minute win

The editor is controlled: you hold the document, it dispatches changes. That makes persistence one JSON.stringify away, and undo, autosave or drafts a matter of what you do with the state.

import { EmailBlockEditor, type EmailEditorDocument, emptyEmailEditorDocument } from "@voila.dev/ui/email-block-editor";
import { useState } from "react";
 
export function CampaignEditor() {
	const [document, setDocument] = useState<EmailEditorDocument>(
		emptyEmailEditorDocument,
	);
 
	return (
		<EmailBlockEditor
			document={document}
			onChange={setDocument}
			onUploadImage={async (file) => {
				// Upload to your own storage, resolve with the public URL.
				const { url } = await uploadImage(file);
				return url;
			}}
		/>
	);
}

Omit onUploadImage to disable image uploads. Pass headerSlot and footerSlot to replace the neutral header and footer placeholders with your own chrome, and generateBlockId for deterministic ids in tests.

Mental model

The document is plain data; the editor is one view of it. A document is { version, blocks }, every block a small readonly object with a type field, and the whole thing serialises to JSON without ceremony. The editor never emits HTML: placeholders like {{firstName}} stay as tokens, prices are integers in minor units, and rich text is a flat list of spans. Your server walks the same structure to produce the sent email, which is why the canvas can promise to be an honest preview rather than an approximation.

Every building part is exported individually (the block definitions, the sections, the drag-and-drop list, the reducer), so the composed EmailBlockEditor is a convenience, not a boundary. Read the code, then keep it.

API

PropTypeDefault
documentEmailEditorDocumentrequiredThe template being edited: { version, blocks }, plain serialisable data. Controlled — the editor holds no copy of it, so persistence is one JSON.stringify away and undo is whatever you do with the state.
onChange(document: EmailEditorDocument) => voidrequiredCalled with the whole next document on every edit, keystrokes included.
footerSlotReactNodeReplaces the neutral footer placeholder below the canvas.
generateBlockId(() => string)() => crypto.randomUUID()Block-id factory, injectable for deterministic tests.
headerSlotReactNodeReplaces the neutral header placeholder above the canvas with your own chrome.
onUploadImage((file: File) => Promise<string>)Delegated image upload: receives the picked file, resolves with its public URL. Omit to disable image uploads.

Plus the DOM props of the element it renders. Source: email-block-editor/components/email-block-editor.tsx.

This is the only component the package exports. Everything else on the pages below is a block — a plain readonly object with a type field, not a component with props — so those pages document a data shape instead of a table. The registry behind them, EMAIL_BLOCK_DEFINITIONS, is public too, so a host can render the same blocks outside the editor.

Page map

The blocks:

  • HeadingBlock: the email's title, or a section heading inside it.
  • ParagraphBlock: rich text as a flat list of spans.
  • ButtonBlock: the call to action, filled or outlined.
  • ImageBlock: an image, optionally linked, with a play-badge overlay for video thumbnails.
  • ListBlock: bullets, numbers or badges, with an optional bold lead-in per item.
  • DividerBlock: the horizontal rule between sections.
  • GridBlock: a multi-column row any leaf block can sit in.
  • StatBlock: one figure with a label; three of them make a stats row in a grid.
  • TableBlock: plain-text rows and columns, with an optional header row.
  • ArticleBlock: a blog post or resource card.
  • ProductBlock: a product card with price and compare-at price.
  • OfferBlock: a plan card with features and an optional highlight.
  • RatingBlock: a one-to-five question where each step is its own tracked link.

The features: