Skip to content

Merge Tags

Merge tags are tokens for dynamic content -- things like a recipient's name, a product price, or an unsubscribe URL. They appear as highlighted tokens in the editor and pass through unchanged in the rendered MJML. Your email sending platform replaces them with real values at send time.

Templatical provides built-in syntax presets for popular platforms and supports custom syntax definitions.

Configuration

Pass a tags array to register your merge tags with the editor. When the editor detects a merge tag value in the content (e.g. {{first_name}}), it replaces it visually with the human-readable label ("First Name") — making the template much easier to read and edit. The raw value is preserved in the output.

Data tag displayed in the editor

Hovering over a tag reveals the raw value behind the label.

The syntax property is optional and defaults to 'liquid'.

ts
import { init } from '@templatical/editor';

const editor = await init({
  container: '#editor',
  mergeTags: {
    tags: [
      { label: 'First Name', value: '{{first_name}}' },
      { label: 'Last Name', value: '{{last_name}}' },
      { label: 'Email', value: '{{email}}' },
      { label: 'Company', value: '{{company.name}}' },
      { label: 'Unsubscribe URL', value: '{{unsubscribe_url}}' },
    ],
  },
});

MergeTag type

Each tag is defined with a label (shown in the editor UI) and a value (the full merge tag string including delimiters). Two optional fields — group and description — are used by the built-in picker to organize and explain tags:

ts
interface MergeTag {
  label: string;
  value: string;
  group?: string;        // optional grouping shown in the picker
  description?: string;  // optional helper text shown in the picker
  sample?: string;       // optional example value shown in previews
}

The value must include the syntax delimiters. For example, with Liquid syntax:

value: '{{first_name}}'

The group and description fields are picker-only — they do not appear in the editor canvas, in autocomplete, or in the rendered MJML output. They are ignored if you only use onRequest for tag selection.

Sample values

A tag can carry a sample — an example value that preview surfaces render in its place, so a preview reads like a delivered email instead of a list of field names:

ts
mergeTags: {
  tags: [
    { label: 'First Name', value: '{{first_name}}', sample: 'Ada' },
    { label: 'Plan', value: '{{plan_name}}', sample: 'Pro' },
  ],
}

Setting sample is the whole opt-in — there is no flag to enable alongside it. The value never leaves the preview: it is not written to the template, not returned by getContent(), not sent, and not present in MJML output. It also shows in the built-in picker, so an author can see what a tag will render before inserting it.

How previews use it — the Sample / Label switch, which tags keep their highlight, and what happens on the editing canvas — is covered in Preview Rendering. That page also documents resolvePreview, the hook for having your own backend resolve a preview, which is the only way to evaluate logic tags.

Syntax presets

Templatical includes four built-in syntax presets. The syntax setting tells the editor how to detect and highlight both data tags and logic tags in content.

Each preset defines two patterns:

  • Data tags -- variable merge tags like a recipient's name or email
  • Logic tags -- control flow statements like conditionals and loops
PresetData tagLogic tagPlatform
'liquid'{{first_name}}{% if vip %}Shopify, Jekyll, Django, Jinja2
'handlebars'{{first_name}}{{#if vip}}Handlebars.js, Mandrill
'mailchimp'*|FIRST_NAME|**|IF:VIP|*Mailchimp
'ampscript'%%=first_name=%%%%[IF @vip]%%Salesforce Marketing Cloud
ts
mergeTags: {
  syntax: 'handlebars',
  tags: [
    { label: 'First Name', value: '{{first_name}}' },
  ],
}

Logic tag highlighting

Beyond data tags, the editor also recognizes logic tags -- conditional statements, loops, and other control flow syntax used by your email platform. These are detected automatically using the logic regex pattern from the selected syntax preset.

When a logic tag is detected in content, the editor extracts the keyword (the first capture group from the logic regex) and displays it as an uppercase badge -- for example, {% if customer.vip %} renders as IF and {% endif %} renders as ENDIF. Hovering over the badge shows the full tag value as a tooltip. Users can click the badge to edit the raw value.

Logic tag displayed in the editor

Logic tags are styled differently from data tags (outlined badge with primary color vs filled background) so template authors can distinguish between data tags and control flow at a glance.

Like data tags, logic tags pass through unchanged in the rendered MJML — your sending platform evaluates them at send time.

Inserting logic tags

This section covers highlighting — any logic tag you type or paste is detected automatically. To let users insert logic tags without typing them (a dedicated Logic button, condition/loop blocks that wrap a selection), see the separate Logic Tags guide. Logic is configured independently of merge tags.

Examples of logic tags by preset:

html
{% if customer.vip %}
  <p>Exclusive offer just for you!</p>
{% endif %}

{% for item in cart.items %}
  <p>{{item.name}} - {{item.price}}</p>
{% endfor %}
html
{{#if hasSubscription}}
  <p>Your plan renews on {{renewal_date}}</p>
{{/if}}

{{#each products}}
  <p>{{this.name}}</p>
{{/each}}
html
*|IF:VIP|*
  <p>VIP discount applied</p>
*|END:IF|*
html
%%[IF @subscriber_type == "premium"]%%
  <p>Premium content here</p>
%%[ENDIF]%%

Custom syntax

If the built-in presets don't match your platform, define a custom syntax with two regex patterns -- one for data tags and one for logic tags:

ts
interface SyntaxPreset {
  value: RegExp;  // matches data tags like ${user.name}
  logic: RegExp;  // matches logic tags like $[IF ...]
}

Example for a ${...} / $[...] syntax:

ts
mergeTags: {
  syntax: {
    value: /\$\{.+?\}/g,
    logic: /\$\[\s*(\w+).*?\]/g,
  },
  tags: [
    { label: 'User Name', value: '${user.name}' },
    { label: 'Order Total', value: '${order.total}' },
  ],
}

The value regex detects data tags. The logic regex detects control flow statements — the first capture group (\w+) extracts the keyword (e.g., IF, FOR) which the editor uses as the display label.

Autocomplete

When users type the syntax opener (e.g. {{ for Liquid/Handlebars, *| for Mailchimp, %%= for AMPscript), the editor surfaces a popup listing matching tags from the configured tags array. Selecting an item (mouse click, Enter, or Tab) inserts it as a merge tag — the same form produced by the toolbar picker. Esc or clicking elsewhere dismisses the popup.

Autocomplete works both inside title/paragraph rich-text blocks and in every merge-tag-enabled input and textarea field (button and image URLs, image alt text, video and menu links, template settings, and custom-block text fields). The popup, filtering, keyboard navigation, and positioning are identical across both surfaces.

Filtering is case-insensitive and matches against both label and value. The list is capped at 10 results.

Autocomplete is enabled by default. It is automatically disabled when:

  • tags is empty (no candidates to suggest), or
  • syntax is a custom regex (the editor cannot infer a trigger string from arbitrary regexes).

To opt out explicitly, set autocomplete: false:

ts
const editor = await init({
  container: '#editor',
  mergeTags: {
    autocomplete: false,
    tags: [
      { label: 'First Name', value: '{{first_name}}' },
    ],
  },
});

The toolbar's Merge tag button continues to work regardless of the autocomplete setting.

Built-in picker

When you configure mergeTags.tags without an onRequest callback, clicking the Merge tag button in the rich text toolbar (or next to a sidebar text input) opens a built-in modal picker. The picker lists every tag from tags, supports keyboard navigation, and offers a search field that matches against label, value, and description.

Built-in merge tag picker

The picker shows:

  • the label (bold)
  • the raw value (mono, dim)
  • the optional description (small, dim) when set

When at least one tag carries a group field, the picker renders sectioned headers in insertion order (the order tags appear in your tags array). Tags without group fall under a localized "Other" header. When no tag has a group, the picker renders a plain flat list — no headers, no "Other" bucket.

Typing in the search field flattens groups and filters the list. Case-insensitive substring matches against the tag's label, value, or description. Clearing the search restores the grouped (or flat) layout.

Single-step insert: clicking a row, or pressing Enter on the highlighted row, inserts the tag and closes the modal. Esc, the header close (×), or clicking the backdrop all dismiss the picker without inserting.

ts
const editor = await init({
  container: '#editor',
  mergeTags: {
    tags: [
      {
        label: 'First Name',
        value: '{{first_name}}',
        group: 'Recipient',
        description: 'Personalized greeting',
      },
      {
        label: 'Last Name',
        value: '{{last_name}}',
        group: 'Recipient'
      },
      {
        label: 'Company',
        value: '{{company.name}}',
        group: 'Account'
      },
      {
        label: 'Unsubscribe URL',
        value: '{{unsubscribe_url}}',
        description: 'Required by anti-spam legislation',
      },
    ],
  },
});

Dynamic tag loading

For large or context-dependent tag lists, use the onRequest callback instead of (or in addition to) a static tags array. The editor calls this function when the user clicks to insert a merge tag. Use it to open a custom picker modal, fetch available merge tags from your API, or build a context-aware tag list based on the current user. Return the selected MergeTag or null to cancel.

ts
const editor = await init({
  container: '#editor',
  mergeTags: {
    onRequest: async () => {
      const tag = await showMyMergeTagPicker();
      return tag; // MergeTag or null if cancelled
    },
  },
});

Precedence

If you provide both tags and onRequest, onRequest takes precedence — the Merge tag button always calls your callback. The static tags array still powers the typing-autocomplete suggestion list.

Tokens in loaded content

Content that never passed through the editor — a template from your own store, or one produced by the @templatical/import-* converters — carries merge tags as bare {{tokens}} rather than as tag nodes. The editor converts them on the way in, so a loaded tag behaves exactly like a typed one: human label, highlight, sample, and selectable as a single unit.

There is nothing to call and nothing to enable. It runs wherever content arrives:

PathWhen
init({ content }) / initCloud({ content })before mount
editor.setContent(content)before the content reaches the canvas
editor.create({ content })before the content becomes editor state
editor.load(id)as the templates provider's result returns
Version history preview and restoreas each version reaches the canvas

Matching follows your configured syntax, not the tags array, so an undeclared token still becomes a tag — labelled with its own raw value.

Only text is converted. A token in an href, src or any other attribute is left byte-identical:

html
<!-- in -->
<p>Hi {{first_name}} — <a href="{{unsubscribe_url}}">unsubscribe</a></p>

<!-- out -->
<p>Hi <span data-merge-tag="{{first_name}}">First Name</span> —
   <a href="{{unsubscribe_url}}">unsubscribe</a></p>

Only rich text is convertedTitleBlock.content and ParagraphBlock.content. Every other merge-tag-bearing field is rendered as text and keeps its bare tokens: button text and URLs, image src/alt, HtmlBlock.content, custom-block field values, settings.preheaderText, and table cells.

getContent() is not a byte-for-byte round-trip

A loaded template's bare tokens come back as tag nodes. Nothing is written to your store unless you save, and nothing is marked as an unsaved change — but expect a one-time difference if you diff or checksum stored templates.

Output is unaffected: toMjml() / toHtml() replace a tag node with its token, so a converted template and its bare-token original compile identically.

Writing a resolvePreview hook

Your resolvePreview callback receives tag nodes, including for content that arrived as bare tokens. Match on the tag markup, not the raw token — a naive replaceAll('{{first_name}}', 'Grace') also hits the token inside data-merge-tag="{{first_name}}" and silently produces a tag that renders its label.

Merge tags in other inputs

Merge tags aren't limited to title and paragraph blocks. The editor detects and highlights merge tags in other block inputs too — button text, button URL, image URL, image alt text, and link href values. The same label replacement and tooltip behavior applies in these fields.

Merge tag in a button URL

Using merge tags outside the editor

The editor handles merge tags on every surface it owns — rich-text blocks, the toolbar picker, and the other block inputs above. For inputs outside the editor, such as an email subject field in your own app, build a small field of your own using the merge-tag primitives that @templatical/types exports. These are the same functions the editor uses internally, so your field stays consistent with whatever syntax you configured the editor with.

The package (MIT) exports the full toolkit:

  • SYNTAX_PRESETS — the built-in syntax definitions (liquid, handlebars, mailchimp, ampscript)
  • getSyntaxTriggerChar / getSyntaxClosingChar — a preset's opening/closing delimiters, for autocomplete detection
  • isMergeTagValue, getMergeTagLabel, containsMergeTag — matching and label resolution
  • isLogicMergeTagValue, getLogicMergeTagKeyword — the same for logic tags
  • the MergeTag and SyntaxPreset types

Render a stored value as labeled chips

Split a raw string into plain text and resolved tag labels — essentially the editor's own segmentation:

ts
import { SYNTAX_PRESETS, getMergeTagLabel, type MergeTag } from '@templatical/types';

const syntax = SYNTAX_PRESETS.liquid;
const tags: MergeTag[] = [{ label: 'First name', value: '{{first_name}}' }];

function segments(value: string) {
  const re = new RegExp(syntax.value.source, 'g');
  const out: { text: string; isTag: boolean; label?: string }[] = [];
  let last = 0;
  let m: RegExpExecArray | null;
  while ((m = re.exec(value))) {
    if (m.index > last) out.push({ text: value.slice(last, m.index), isTag: false });
    out.push({ text: m[0], isTag: true, label: getMergeTagLabel(m[0], tags) });
    last = m.index + m[0].length;
  }
  if (last < value.length) out.push({ text: value.slice(last), isTag: false });
  return out;
}

// segments('Hi {{first_name}}!') →
//   [ { text: 'Hi ', isTag: false },
//     { text: '{{first_name}}', isTag: true, label: 'First name' },
//     { text: '!', isTag: false } ]

Autocomplete on a plain input

The delimiter helpers keep your own dropdown syntax-accurate across every preset:

ts
import { SYNTAX_PRESETS, getSyntaxTriggerChar, getSyntaxClosingChar } from '@templatical/types';

const syntax = SYNTAX_PRESETS.liquid;
const open = getSyntaxTriggerChar(syntax);   // '{{'
const close = getSyntaxClosingChar(syntax);  // '}}'

// On each keystroke, look at the text before the caret: if it contains an
// unclosed `open` delimiter, take the fragment after it as the query and
// filter your tags into a dropdown of your own.

Owning the field means it renders in your framework, styled to your design system, against your own tag model — which for something like a subject line is usually what you want.