The features other editors gate. In your repo. Free to embed.
The power features, and a clean set of essentials — all included, nothing gated. 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
- LanguagesLanguage versions in one template
- PreviewPreviews with real data, resolved by your backend
- BrandingTheming and brand defaults
- IsolationDrop into any page — host CSS can't interfere
- LintingTemplate quality checks before send
- AssetsBring your own media browser
- HeadlessBuild templates without the editor
Connect your backend
- PersistenceSaving and loading, against your own storage
- HistoryBrowse, preview and restore past versions
- ReviewThreaded review, anchored to blocks
- CollaborationReal-time co-editing, over your own backend
- AIAI in the editor, on your own model
- TranslationTranslation through your own provider
- ReuseSaved blocks, in your storage
- DeliveryTest sends through your own infrastructure
- MediaA media library, in your own storage
- OutputJSON in, MJML out
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,
},
})Language versions in one template
One template keeps every language of an email: a shared block tree, plus each language’s text, property overrides such as links and images, and hidden blocks. Authors switch versions from the Languages menu, see what is missing or out of date in the language bar and on each block, and the default language’s text stands in wherever a translation is missing.
One email to maintain, however many languages it ships in.
- localization.locales lists the languages authors can add; existing templates need no migration
- Structure is shared: adding, moving or deleting a block changes every language
- A translation of an older default is Out of date until an author re-translates it, keeps it or uses the default text
- renderToMjml(content, { locale }), editor.toHtml({ locale }) and template-tools render --locale render one language, with its lang and dir
- @templatical/quality reports localization.untranslated, localization.stale and localization.merge-tags-differ, and template-tools validate --locale runs them in CI
- template-tools strings export and strings import exchange a language’s strings as JSON for a translation management system; XLIFF and CSV aren’t supported
- The Beefree, Unlayer and Topol importers keep the source’s languages
- Comments record their language; version history stays per template, and column structure can’t differ per language
- The editor’s own UI language, init({ locale }), is separate from the template’s languages
const editor = await init({
container: '#editor',
// The languages authors can add from the Languages menu.
// settings.locale stays the template's default.
localization: {
locales: ['en', 'de', 'fr'],
// Masked during Translate and checked on return.
doNotTranslate: ['Acme'],
},
})
// Render one language version without switching the view.
const german = await editor.toMjml({ locale: 'de' })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',
},
})Template quality checks before send
The MIT quality linter checks accessibility, structure, links, deliverability patterns (not inbox placement), and email-client compatibility while you author. The Issues tab lists findings; errors and warnings also have canvas badges.
Catch template issues before send, with a suggestion and reference link for each finding.
- Accessibility: alt text, contrast, heading order, and touch targets
- Structure and links: duplicate IDs, empty sections, unsafe URLs, and staging hosts
- Deliverability flags content patterns named by spam filters and mailbox-provider guidelines; it does not predict inbox placement
- Caniemail-backed checks of authored pass-through HTML and CSS name affected clients from a dated snapshot
- Every issue has a message, suggestion, and reference link; auto-fixes are available where a rule provides one
- The Issues tab groups findings by severity; canvas badges mark errors and warnings
- In the local editor, Check size measures compiled HTML for Gmail clipping when an HTML compiler is configured
- Run lintTemplate headlessly or templatical validate in CI and agents; validate --html adds compiled size
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()),
},
})Real-time co-editing, over your own backend
Several people edit one template at once. The editor sends every edit to your backend as a batch of operations, your backend puts the batches in one order, and every open editor applies them in that order. Selecting a block locks it, and the header shows who else has the template open.
Live co-editing on your own realtime transport and template store.
- join, submit and operationsAfter are the contract; acquireLock, renewLock and releaseLock add block locks
- Your backend applies each batch with applyOperations from @templatical/types, the engine the editor runs
- A resent batch with a known batchId answers its revision and applies nothing, so retries are safe
- Undo and redo send inverse operations; a step that another person’s edit has overtaken is skipped with a notice
- Other open editors name an agent’s or the AI’s batches in a notice and outline the blocks they changed
- With collaboration on, templates.load also returns the revision, and templates.save receives only a new name
- No realtime channel? Edits still go through submit in order, and other people’s edits appear on the next load
let sessionId = ''
const editor = await init({
container: '#editor',
user: { id: currentUser.id, name: currentUser.name },
// With collaboration on, load also returns the template's revision, and
// save receives only a new name: content arrives through submit.
templates: myTemplatesProvider,
collaboration: {
// Subscribe to the template's events, answer with its revision, locks
// and members, and call the handlers for everything that follows.
join: (templateId, session, handlers) => {
sessionId = session.sessionId
return myRealtime.join(templateId, session, handlers)
},
// api() is your fetch helper. A refusal rejects with the HTTP status as
// statusCode and the response's code, or it is retried as a network error.
submit: (templateId, batch) =>
api(`/api/templates/${templateId}/operations`, {
method: 'POST',
headers: { 'X-Session-Id': sessionId },
body: JSON.stringify(batch),
}),
operationsAfter: (templateId, revision) =>
api(`/api/templates/${templateId}/operations?after=${revision}`, {
headers: { 'X-Session-Id': sessionId },
}),
// Optional together: without them the editor takes no locks.
acquireLock: (templateId, blockId) =>
api(`/api/templates/${templateId}/locks`, {
method: 'POST',
headers: { 'X-Session-Id': sessionId },
body: JSON.stringify({ blockId }),
}),
renewLock: (templateId, blockId) =>
api(`/api/templates/${templateId}/locks/${blockId}`, {
method: 'PUT',
headers: { 'X-Session-Id': sessionId },
}),
releaseLock: async (templateId, blockId) => {
await fetch(`/api/templates/${templateId}/locks/${blockId}`, {
method: 'DELETE',
headers: { 'X-Session-Id': sessionId },
keepalive: true,
})
},
},
})
await editor.load('tpl_123')AI in the editor, on your own model
Pass an AI provider and the editor gains the AI assistant, a chat that edits the template with one undo step per turn and takes image and PDF attachments; Rewrite in the paragraph toolbar, with a Translate preset; Fix with AI on lint findings; and Write alt text for one image or in bulk. Your backend calls the model, with your keys and your data-processing terms.
The editor’s AI on the model, keys and terms you already have.
- From @templatical/ai-kit (MIT): createHttpAiProvider talks to your backend over a documented REST and server-sent-events wire, and the server kit has the prompts, the apply_operations tool and the editing loop for any Fetch-API runtime
- Model adapters: createAnthropicAdapter, createOpenAiAdapter (Chat Completions) and createVercelAdapter (an AI SDK model or a gateway model id, run with streamText), from @templatical/ai-kit/adapters/anthropic, /openai and /vercel. Each takes images, PDFs and image URLs; through the Vercel adapter, PDF support depends on your AI SDK provider package
- Anthropic and OpenAI take a client you construct, so the adapter reads no environment and holds no keys: an Anthropic client may set its own baseURL, and OpenAI also takes new AzureOpenAI() or any OpenAI-compatible Chat Completions endpoint through baseURL. Each provider SDK is an optional peer: install the one for the adapter you import, and nothing else installs. Any other model plugs in through AiModelAdapter
- With attachments on, Import from screenshot or PDF on the empty canvas opens the AI assistant with the files and a prefilled request; nothing sends until the author does
- Optional stored history, with Undo and Redo replayed by your backend; without it, the chat lives in memory for the session
- Conformance suites check your provider and routes; an eval set measures your model on edits, attachments, lint fixes and alt text
- Fix with AI and Write alt text need @templatical/quality with lint on
- In a language view, every AI feature edits that language; Rewrite’s Translate preset translates one paragraph, and whole language versions go through the translation provider
- The template, its merge tags and the prompts go to the model you call
import { createHttpAiProvider } from '@templatical/ai-kit/client'
const editor = await init({
container: '#editor',
// Every AI request names a template, so the AI button appears once one
// is loaded.
templates: myTemplatesProvider,
// Your backend serves the routes with @templatical/ai-kit/server and
// calls the model with your keys. Nothing passes through Templatical.
ai: createHttpAiProvider({ baseUrl: '/api/ai' }),
})
await editor.load('tpl_123')Translation through your own provider
Pass a TranslationProvider, any object with a translate method, and the language bar gains Translate. It sends a version’s missing and out-of-date fields to your backend, which can call an LLM, DeepL or a translation management system. Merge tags, logic tags and do-not-translate terms travel as numbered placeholders, and a returned field is applied only when every placeholder comes back.
Translation on the service, keys and terms you already have.
- One method and no extra package: translate(request, signal) returns the fields it translated, and an omitted field keeps its previous value
- Translate sends only the gaps, so current translations and manual edits survive; Translate everything again replaces them after a confirmation
- Optional, from @templatical/ai-kit (MIT): createHttpTranslationProvider in the browser; readTranslationRequest and translateFields on the server, which run any AiModelAdapter and repair a failed field once
- At most 50 fields and 20,000 characters per call; on content_too_large the editor halves the batch and retries
- localization.doNotTranslate protects up to 500 terms, such as brand names, with the same exact check
- HTML blocks, empty defaults and hidden blocks aren’t sent; translate HTML blocks by hand
- The kit also has runTranslationConformance, which checks any provider, and createMockTranslationProvider, which answers from recordings for demos and tests
- Subject lines aren’t part of the template: translate them with the same provider and store them in your own data
- The fields and the language pair go to the service your backend calls
const editor = await init({
container: '#editor',
localization: { locales: ['en', 'de'], doNotTranslate: ['Acme'] },
// Any object with translate(request, signal); no package needed. Your
// backend calls DeepL, a translation management system or an LLM with
// your keys. The editor masks merge tags and protected terms before the
// call and checks every placeholder in the answer.
translation: {
async translate(request, signal) {
const res = await fetch('/api/translate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request),
signal,
})
if (!res.ok) throw new Error(res.statusText)
return res.json() // { fields: [{ id, text }] }
},
},
})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.