Skip to content
Open-source SDK

The features other editors gate. In your repo. MIT-licensed.

The power features, and a clean set of essentials — all included, all open. Portable JSON in, MJML out, no usage tier in the way.

Open source · MIT · Free to self-host
AI

Describe the email, get the template

An open-source Agent Skill teaches Claude Code, Cursor, or any AI coding agent to build Templatical templates from a prompt — validated against the block schema before you ever see them. No backend, no API key, nothing sent to us.

A first draft in one sentence, then edit it like any other template.

  • Runs on the agent you already use — the model is the inference
  • Every generated template is schema-validated and quality-linted
  • Live mode previews and hand-edits in the real editor, then reconciles
  • Imports existing Unlayer, BeeFree, or HTML templates
  • Zero install — dependencies are vendored, so a bare copy works offline
Agent Skill guide
  • 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.”
  • Polish
    “Fix the accessibility warnings and shorten the preheader to 90 characters.”
Extensibility

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
See the block API
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>
      `,
    },
  ],
})
Personalization

Merge tags with pluggable syntax

Handlebars, Liquid, JS template literals, or your own — with human-readable labels rendered directly on the canvas. No vendor-locked syntax.

Build a CRM-aware tag picker in an afternoon, not a sprint.

  • Built-in syntaxes plus a hook for your own
  • Human-readable labels rendered directly on the canvas
  • Inline autocomplete — type the syntax opener to surface matching tags
  • Optional sample values render in previews instead of the label
  • Optional onRequest hook to swap the picker for your CRM UI
  • Round-trip safe — JSON stores the canonical token
Merge-tag reference
const editor = await init({
  container: '#editor',
  mergeTags: {
    syntax: 'liquid',
    // An optional sample is what previews render in place of the label,
    // so a preview reads like a delivered email instead of a list of
    // field names. Set one and a Sample / Label switch appears; set
    // none and the editor behaves exactly as it did before.
    tags: [
      { label: 'First name',      value: '{{first_name}}',  sample: 'Ada' },
      { label: 'Email',           value: '{{email}}',       sample: '[email protected]' },
      { label: 'Plan name',       value: '{{plan_name}}',   sample: 'Pro' },
      { label: 'Order ID',        value: '{{order_id}}',    sample: 'A-4417' },
      { label: 'Order total',     value: '{{order_total}}', sample: '$128.00' },
      // No sample — this one keeps its label and its highlight, so the
      // remaining highlights double as a list of what's still missing.
      { label: 'Unsubscribe URL', value: '{{unsubscribe_url}}' },
    ],
  },
})
Reuse

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
Saved-blocks reference
import { createLocalStorageSavedBlocksProvider } from '@templatical/editor'

const editor = await init({
  container: '#editor',
  // Stores entries in localStorage — no backend, good for demos.
  savedBlocks: createLocalStorageSavedBlocksProvider(),
})
Targeting

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
Conditions guide
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,
  },
})
Dynamic content

Loops and conditionals inside the copy

Register your template language’s control flow — Liquid, Handlebars, whatever you already send — and authors insert it from a picker. Tags render as styled pills in the rich text and pass through to the output untouched.

Authors write conditional copy without learning your syntax.

  • Wraps a phrase mid-sentence, where display conditions wrap a whole block
  • Standalone tags and open/close pairs, grouped in the picker
  • A pair wraps the current selection — no manual closing tag
  • Also available in inputs: button text, URLs, alt text
  • Passes through to the rendered MJML unchanged
  • Or hand off to your own picker with a single onRequest hook
Logic tags reference
const editor = await init({
  container: '#editor',
  logicTags: {
    tags: [{ label: 'Else', value: '{% else %}', group: 'Conditions' }],
    pairs: [
      {
        label: 'If VIP',
        before: '{% if customer.vip %}',
        after: '{% endif %}',
        group: 'Conditions',
        description: 'Show the wrapped copy only to VIP customers',
      },
      {
        label: 'Loop items',
        before: '{% for item in order.items %}',
        after: '{% endfor %}',
        group: 'Loops',
      },
    ],
  },
})
Preview

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
Preview-rendering guide
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)
  },
})
Delivery

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
Test-email reference
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.')
    },
  },
})
Branding

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
Theming & defaults reference
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',
    },
  },
})
Integration

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
Style-isolation guide
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',
  },
})
Quality

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
Linting reference
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.
  },
})
Assets

Pluggable media library

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
  • Cloud build adds a managed media browser when you opt in
Media-library reference
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 }
  },
})
Output

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
How rendering works
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)
Headless

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
Programmatic templates guide
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 })
The essentials

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.
Painless migration

Already in another editor? Bring your templates with you.

Import existing templates from major hosted editors — or any HTML email you already have. Free, open-source migration tools, no manual rebuilding, no vendor lock-in.

  • Import legacy JSON templates directly
  • Convert raw HTML emails — MJML, Mailchimp, SendGrid, hand-coded
  • Automatic block mapping and style preservation
  • Free and open-source migration tools
Get started

Pick 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.