The features other editors gate. In your repo. Open source.
The power features, and a clean set of essentials — all included, all open. Portable JSON in, MJML out, no usage tier in the way.
Everything on this page
In the editor
- Agent SkillOne skill for the template and the integration
- ExtensibilityCustom blocks with API-backed data
- PersonalizationMerge tags with pluggable syntax
- TargetingDisplay conditions
- Dynamic contentLoops and conditionals inside the copy
- PreviewPreviews with real data, resolved by your backend
- BrandingTheming and brand defaults
- IsolationDrop into any page — host CSS can't interfere
- LintingBuilt-in template linting
- AssetsBring your own media browser
- HeadlessBuild templates without the editor
Connect your backend
One skill for the template and the integration
An open-source Agent Skill that gives any AI coding agent both halves of the job: writing Templatical templates from a prompt, validated against the block schema before you ever see them, and wiring @templatical/editor into your own application. It routes each request itself — you never pick a mode. No backend, no API key, nothing sent to us.
A first draft in one sentence — and the integration that ships it, in the same session.
- Runs on the agent you already use — the model is the inference
- One command installs it into every skills-compatible agent on your machine
- Every generated template is schema-validated and quality-linted
- Live mode previews and hand-edits in the real editor, then reconciles
- Dedicated converters for the major email builders — anything else it maps onto the block schema by hand
- Mounts the editor in your app: proposes the change first, then checks it against your running dev server
- Debugs an integration that misbehaves against a table of verified traps
- From scratch
“A product-launch email for our new Pro tier — hero, three feature callouts, and a button to the changelog.”
- Migrate
“Import this Unlayer export and rebuild the image-only header as real text.”
- Refine live
“Show it live. The CTA is too quiet — make it the accent colour and move it above the fold.”
- Integrate
“Mount the editor in our Next.js admin and point save and load at our own API.”
- Diagnose
“The editor’s dropdowns render behind the modal it’s mounted in. Why?”
Custom blocks with API-backed data
Register your own block types — static templates or live data fetched from your API at preview time. Built in, not bolted on.
Ship CRM-aware blocks your team drops in without engineering tickets.
- Per-field config: text, image, color, select, repeatable arrays
- Static template or live API fetch at preview time
- Liquid templates with conditionals and built-in filters
- Type-safe block factories with full TypeScript types
const editor = await init({
container: '#editor',
customBlocks: [
{
type: 'event-details',
name: 'Event Details',
description: 'Date, time, location, and a map link',
fields: [
{ type: 'text', key: 'eventName', label: 'Event Name', required: true },
{ type: 'text', key: 'date', label: 'Date', default: 'April 15, 2026' },
{ type: 'text', key: 'location', label: 'Location' },
{ type: 'text', key: 'mapUrl', label: 'Map Link (optional)' },
{ type: 'color', key: 'accent', label: 'Accent', default: '#7c3aed' },
],
template: `
<div style="border: 2px solid {{ accent }}; padding: 20px; border-radius: 8px;">
<h3 style="color: {{ accent }};">{{ eventName }}</h3>
<p>📅 {{ date }} · 📍 {{ location }}</p>
{% if mapUrl %}
<a href="{{ mapUrl }}">View on Map →</a>
{% endif %}
</div>
`,
},
],
})Display conditions
Show or hide blocks based on recipient attributes, with live preview in the editor. Built in, not a paid add-on.
Personalize without bolting on a separate targeting service.
- Per-block show/hide rules from recipient attributes
- Live preview while editing
- allowCustom: true lets editors add conditions inline
- Wrappers are opaque strings — any syntax your ESP evaluates at send time
const editor = await init({
container: '#editor',
displayConditions: {
conditions: [
{
label: 'VIP Partners',
before: '{% if vip_partner %}',
after: '{% endif %}',
group: 'Audience',
description: 'Show only to VIP partner accounts',
},
{
label: 'Early Bird',
before: '{% if early_bird %}',
after: '{% endif %}',
group: 'Registration',
},
],
allowCustom: true,
},
})Previews with real data, resolved by your backend
The editor recognises merge tags and logic tags — it never evaluates them. Hand it a resolvePreview callback and whatever already renders your sends renders your previews too, with branches taken and data filled in.
A preview that agrees with the delivered email by construction, not by approximation.
- Your engine, your data, your template language — nothing to reimplement in the browser
- Evaluates logic that sample values structurally cannot — conditional branches collapse to the one that applies
- Resolves for the selected recipient in the test-email dialog
- Display-only — resolved content never reaches getContent(), export, or a send
- A resolver outage degrades to the unresolved template and says so, never a blank preview
- Never runs while editing — the canvas always shows the tag you inserted
const editor = await init({
container: '#editor',
// Called by preview surfaces only — never while editing. The
// test-email dialog passes the selected address as `recipient`;
// the editor's own preview mode has none.
resolvePreview: async ({ content, recipient }) => {
const data = recipient
? await fetchSubscriber(recipient)
: await fetchSampleSubscriber()
// Whatever renders your sends renders your previews.
return renderWithMyEngine(content, data)
},
})Theming and brand defaults
27 OKLch tokens, custom fonts, dark mode, complete theme overrides. Every surface tokenized — and the same init() call sets the defaults every new template and block starts from.
The editor looks like your product, and every new block starts on-brand.
- 27 OKLch design tokens covering every surface
- Light + dark theme overrides via the same theme.dark key
- Custom fonts via --tpl-font-sans and --tpl-font-mono
- Tailwind 4 with `tpl:` prefix — no preflight, no style leaks
- Per-block-type defaults: button, divider, spacer, image, social
- Template defaults: width, background, font family
const editor = await init({
container: '#editor',
uiTheme: 'auto',
theme: {
'--tpl-color-primary': '#0d9488',
'--tpl-color-accent': '#0ea5e9',
'--tpl-color-background': '#ffffff',
'--tpl-radius': '10px',
'--tpl-font-sans': 'Inter, system-ui, sans-serif',
dark: {
'--tpl-color-primary': '#22d3ee',
'--tpl-color-accent': '#a78bfa',
'--tpl-color-background': '#0b1220',
},
},
})Drop into any page — host CSS can't interfere
The editor mounts inside a Shadow DOM by default. Your app's stylesheets, design system preflight, and CMS template resets stop at the boundary — they never cascade into the toolbar, sidebar, or canvas.
Embed in any framework, CMS, or legacy app — no resets, no !important wars, no surprises after a design-system bump.
- Shadow DOM mount by default — no host CSS leaks in
- Editor styles can't leak out either (tpl: Tailwind prefix in light-DOM mode)
- Project your brand across the shadow boundary via --tpl-user-* CSS variables
- Opt out with shadowDom: false for light-DOM mount when you need it
- Multi-instance safe — each editor gets its own shadow root
const editor = await init({
container: '#editor',
// Shadow DOM by default — host stylesheets stop at the boundary.
// Your design system's preflight, *{ box-sizing }, and body font
// can't cascade into the editor. Set to false for a light-DOM
// mount if you need to inspect editor nodes from host scripts.
shadowDom: true,
// To project your brand across the shadow boundary, set
// --tpl-user-* CSS variables on the container (or any ancestor).
// They inherit through the shadow root.
theme: {
'--tpl-user-color-primary': '#0d9488',
'--tpl-user-font-sans': 'Inter, system-ui, sans-serif',
'--tpl-user-radius': '10px',
},
})Built-in template linting
30 deterministic rules run while authoring — surfaced in a dedicated sidebar tab and as inline badges on the canvas. Accessibility, structure, and links, with configurable severity and no AI guesswork.
Catch alt text, contrast, broken links, and malformed structure before send — not after.
- Live checks: errors, warnings, and info — grouped in the sidebar
- Inline canvas badges with one-click jump and auto-fix where safe
- 20 accessibility rules: alt text, contrast, heading order, touch targets
- 5 link rules: javascript: URLs, malformed mailto and tel, staging hosts
- 5 structure rules: duplicate ids, empty sections, column mismatches
- Per-rule severity overrides and configurable thresholds
- Locale-aware vague-text dictionaries
- Same engine runs standalone — validate templates in CI, on save, or in pre-send pipelines
const editor = await init({
container: '#editor',
// Powered by the optional peer @templatical/quality.
// Lazy-loaded on first use
lint: {
accessibility: {
// Per-rule severity overrides — 'error' | 'warning' | 'info' | 'off'.
rules: {
'a11y.img-missing-alt': 'error',
'a11y.img-alt-is-filename': 'warning',
'a11y.link-target-blank-no-rel': 'off',
},
thresholds: {
minFontSize: 16,
minTouchTargetPx: 44,
},
},
links: {
nonProductionHosts: ['*.staging.*', '*.preview.*'],
},
// Set any linter to false to skip it entirely — e.g. structure: false.
// Or set disabled: true to disable all lint checks.
},
})Bring your own media browser
A single onRequestMedia hook lets the editor open your media browser — S3, Cloudinary, your own CMS, anything. No vendor storage, no asset egress fees, no lock-in.
Reuse the asset pipeline you already run, end-to-end.
- One async hook returns { url, alt } — bring any backend
- Triggered from image blocks, image fields, and the toolbar
- Context-aware accept hint — the editor tells you what it wants
- No upload happens through Templatical — your storage, your auth
- Wins over a media provider when both are set — the built-in modal never opens
- Cloud build adds a managed media browser when you opt in
const editor = await init({
container: '#editor',
// Editor calls onRequestMedia when the user picks an image —
// open your own asset browser (S3, Cloudinary, your CMS, etc.)
// and resolve with { url, alt } — or null on cancel.
async onRequestMedia({ accept } = {}) {
const picked = await openAssetBrowser({
accept, // e.g. ['images']
endpoint: '/api/assets',
})
if (!picked) return null
return { url: picked.url, alt: picked.alt }
},
})Build templates without the editor
Every block type has a factory function in the types package — MIT, no editor, no DOM. Compose a template in a script, seed a starter library, or generate one per customer from your own data.
Templates as data, produced by code as easily as by hand.
- A factory per block type, each with sensible defaults
- Factories generate the ids, so content is valid by construction
- Runs anywhere — build script, server, queue worker, test
- Produces the same JSON the editor reads and writes
- MIT-licensed with no runtime dependencies
import {
createDefaultTemplateContent,
createTitleBlock,
createParagraphBlock,
createButtonBlock,
} from '@templatical/types'
const content = createDefaultTemplateContent()
// Each factory generates its own id, so the result is valid by construction.
content.blocks = [
createTitleBlock({ content: '<h1>Welcome aboard</h1>' }),
createParagraphBlock({ content: '<p>Here is what to do first.</p>' }),
createButtonBlock({ text: 'Open your dashboard', url: 'https://example.com' }),
]
// Feed it to the editor, or straight to the renderer.
await init({ container: '#editor', content })One key per capability. The same shape. Absent until you pass one.
Each backend capability is one config key holding methods you implement. Omit a key and the feature is gone — not disabled, and its UI is never downloaded. Pass false instead of a method and the editor hides that control rather than greying it out.
Saving and loading, against your own storage
Give the editor somewhere to save and it grows the chrome that goes with it: an inline-editable name, a save button, a status indicator, Cmd/Ctrl+S, optional autosave, and a warning before the tab closes with unsaved work.
The whole save lifecycle, with your API as the only storage.
- Three methods are the entire integration — load, create, save
- Debounced autosave that pauses during undo, so a redo never races a write
- The template id is yours — a database key, a slug, a document id
- That id is the join key: version history and comments attach to it
- onSaved carries the trigger — manual, autosave, rename, restore or api
- A failed save leaves editor state untouched; nothing is marked saved that wasn’t
- Omit the key and persist from onChange yourself instead
const editor = await init({
container: '#editor',
templates: {
load: (id) =>
fetch(`/api/templates/${id}`).then((r) => r.json()),
create: (input) =>
fetch('/api/templates', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
}).then((r) => r.json()),
save: (id, patch) =>
fetch(`/api/templates/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
}).then((r) => r.json()),
autoSave: true,
onSaved: (template, { trigger }) => {
if (trigger === 'manual') router.push(`/templates/${template.id}`)
},
},
})
// Opening a template is imperative — your app decides which one.
await editor.load('tpl_123')Browse, preview and restore past versions
A history control in the header steps back through past states, previews one on the canvas with its own banner, and restores it behind a confirmation. Four methods against your own storage.
Undo that outlives the session, without building the UI for it.
- Four-method provider — list, get, create, restore
- Content on a listed version is a per-entry hint: hydrate the recent ones, make the rest a round-trip
- No atomic restore endpoint? Compose it from get plus save
- The confirmation before a restore discards unsaved work is the editor’s job, not yours
- Automatic versions belong to whoever implements save — the side that knows what storage costs
- Pass create: false and the control disappears rather than greying out
const editor = await init({
container: '#editor',
versionHistory: {
list: (templateId) =>
fetch(`/api/templates/${templateId}/versions`).then((r) => r.json()),
// A listed version can omit content as a cache hint — the editor
// calls get() the first time it's opened, then caches the result.
get: (templateId, versionId) =>
fetch(`/api/templates/${templateId}/versions/${versionId}`)
.then((r) => r.json())
.then((v) => v.content),
create: (templateId, content) =>
fetch(`/api/templates/${templateId}/versions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content }),
}).then((r) => r.json()),
restore: (templateId, versionId) =>
fetch(`/api/templates/${templateId}/versions/${versionId}/restore`, {
method: 'POST',
}).then((r) => r.json()),
},
})Threaded review, anchored to blocks
A review panel with threads and replies, a count badge on every commented block, and resolve/reopen. Five methods, plus a top-level user — because without an author the feature reports itself unavailable rather than writing an anonymous comment.
Stakeholder review inside the editor, on your storage and your identities.
- Five-method provider — list, create, update, delete, setResolved
- user.id decides what a session may edit or delete
- setResolved takes the target state, not a toggle, so two clicks can’t invert it
- Comments anchor to a block, or to the template as a whole
- An optional subscribe carries a realtime transport if you have one — and everything works without it
- Pass create: false for a read-only review pass
const editor = await init({
container: '#editor',
// A top-level key, not part of the provider — presence features will
// want the same answer. No user, and the panel reports itself
// unavailable rather than writing an anonymous comment.
user: { id: currentUser.id, name: currentUser.name },
comments: {
list: (templateId) =>
fetch(`/api/templates/${templateId}/comments`).then((r) => r.json()),
create: (templateId, input) =>
fetch(`/api/templates/${templateId}/comments`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
}).then((r) => r.json()),
update: (templateId, commentId, patch) =>
fetch(`/api/templates/${templateId}/comments/${commentId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
}).then((r) => r.json()),
delete: (templateId, commentId) =>
fetch(`/api/templates/${templateId}/comments/${commentId}`, {
method: 'DELETE',
}).then(() => undefined),
// Takes the target state, not a toggle — two clicks in flight can't
// leave a thread inverted.
setResolved: (templateId, commentId, resolved) =>
fetch(`/api/templates/${templateId}/comments/${commentId}/resolve`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ resolved }),
}).then((r) => r.json()),
},
})Saved blocks, in your storage
Users pick a group of blocks, name it, and drop it into any other template. The editor ships the whole experience — pick session, searchable library, live preview, insert at position. You implement four methods against your own API.
A block library your users fill themselves, on your backend.
- Four-method provider — list, create, update, delete
- Pass false instead of a function and the editor hides the control
- Per-entry flags lock individual entries as read-only
- Free-text categories, derived from whatever the entries carry
- Search and category filters run in the editor, not your API
- Bundled browser-local provider for demos — one line, no backend
import { createLocalStorageSavedBlocksProvider } from '@templatical/editor'
const editor = await init({
container: '#editor',
// Stores entries in localStorage — no backend, good for demos.
savedBlocks: createLocalStorageSavedBlocksProvider(),
})Test sends through your own infrastructure
A user mails themselves the template they are editing — and it leaves from your ESP, your domain, your reputation. The editor owns the trigger, the dialog, the preview, and the sending states. You implement one method.
A real inbox check before anything reaches a campaign, with no vendor in the path.
- One send method is the entire integration
- Omit the key and the feature is absent — no button, none of its code downloaded
- The dialog previews exactly what is being sent, desktop or mobile
- Display conditions are honoured, so the preview never shows content the recipient won’t get
- Restrict the recipient list to reshape the field — free text, read-only, or a picker
- Throw with a message and it shows inline; the dialog stays open to retry
const editor = await init({
container: '#editor',
testEmail: {
// The whole integration. A Test button appears in the header, and
// the address the user picks is handed to you.
send: async ({ recipient, content }) => {
const res = await fetch('/api/test-email', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ recipient, content }),
})
// Your message reaches the user verbatim — write it for them.
if (res.status === 429) throw new Error('Too many test emails — try again in a minute.')
if (!res.ok) throw new Error('Could not send the test email.')
},
},
})A media library, in your own storage
The editor owns the picker — browse on image fields, video thumbnails and custom-block fields, drag-and-drop upload, crop, folders, search. You own storage. `list` is the only required method; the other nine are yours to enable or withhold, one at a time.
A media library your users browse and fill, entirely on your own storage.
- Ten-member provider — `list` is the only one that can’t be `false`
- Browse triggers on image fields, video thumbnails and custom-block image fields
- Dropping a file calls `create` directly — the modal never opens, and its listing is untouched
- Pass `false` on any mutation and the editor hides that control instead of disabling it
- Folders come back as a flat list — the UI trees them via `parentId`
- Bundled browser-local provider for demos — one line, no backend
- `onRequestMedia` is a separate seam, not this store — set both and the callback wins
import { createLocalStorageMediaProvider } from '@templatical/editor'
const editor = await init({
container: '#editor',
// Stores entries in localStorage — no backend, good for demos. Folders,
// replace, import, usage, frequently-used and quota are all false.
media: createLocalStorageMediaProvider(),
})JSON in, MJML out
Templates are portable JSON you store wherever you like. Output is MJML, rendered by a package you install — in the browser, on your server, in a queue worker. No hosted render service sits in the path.
Own the output. Send through any provider, for as long as you like.
- MJML is an open standard with implementations in several languages
- Render in the browser, on your server, or in a background job
- Custom blocks resolve through a callback you supply
- Nothing calls home — no render API, no per-render pricing
- The renderer is MIT-licensed and installed separately
- No `render` key needed for `toMjml()` — add one for `toHtml()`, or to move the conversion to your backend
const editor = await init({ container: '#editor' })
// Loads the renderer on first call — custom blocks resolve automatically.
const mjml = await editor.toMjml()
// Compile MJML to HTML with whichever MJML library you prefer.
const { html } = mjml2html(mjml)Everything else you expect — done right.
Drop-in mount, framework-agnostic, every locale you need. Plus the polish — dark mode, undo/redo, responsive preview.
- Blocks out of the box
- Twelve block types ready to drag in — title, paragraph, image, button, section, divider, spacer, social icons, menu, table, video, and raw HTML — plus any custom types you register.
- Drop-in framework integration
- One init() call to mount, one to unmount. First-class examples for React, Vue, Svelte, Angular, and vanilla JS.
- Dark mode
- First-class dark mode with auto-detect or manual toggle. Both themes are designed, not an afterthought.
- Internationalization
- Seven locales built in — English, German, Portuguese (BR), Spanish, Catalan, French, and Dutch — across the editor and the media library. Drop in a file for any other language.
- Undo / Redo
- Full history stack. Debounced to group rapid changes into sensible undo steps.
- Responsive preview
- Toggle desktop, tablet, and mobile viewports to see how every email renders on every device.
Already in another editor? Bring your templates with you.
One importer per source format — hosted editors plus raw HTML and MJML. Free, open-source, MIT, no manual rebuilding and no vendor lock-in.
- Import saved editor documents directly, no re-export step
- Convert raw HTML emails — Mailchimp, SendGrid, hand-coded
- Automatic block mapping and style preservation
- Every converter reports what needs a manual look
BeeFreeUnlayerStripoTopolChamaileonEasy Email ProHTMLMJML
See all importersPick your starting point.
Install the SDK
Add the package, mount with one init() call, ship. First-class examples for every major framework.
Migrate your templates
Already in a hosted editor — or sitting on a folder of HTML emails? Import them with automatic block mapping, no manual rebuild.