Custom Blocks
Plan Feature
This feature is only available on the Scale plan.
Custom blocks let you define your own content types — product cards, event listings, testimonials, pricing tables — that appear alongside built-in blocks in the editor sidebar. Your users drag them into the canvas, fill in the fields, and see a live preview rendered from a Liquid template you provide.
Custom blocks are fully declarative: you pass a JSON configuration at init time. No JavaScript bundles, no iframes, no build step. The SDK handles rendering, field editing, validation, and export.
Connect to Your Data
Custom blocks become even more powerful when paired with data sources. Instead of typing field values manually, your users click a button and the block populates itself from your application — a product from your catalog, an article from your CMS, a listing from your inventory.
Common use cases:
- E-commerce — Drag in a product card, click "Pick a product", and the name, price, and image fill in from your store
- Real estate — Add a property listing block that pulls address, photos, and price from your MLS feed
- Event management — Insert an event block that loads the date, venue, and agenda from your event database
- Content marketing — Use an article block that fetches the title, excerpt, and thumbnail from your CMS
- HR / Internal comms — Create an employee spotlight block that pulls profile data from your directory
Data sources are optional — any custom block works without one. When you do add a data source, the SDK shows a call-to-action overlay prompting the user to load content, and fields you mark as readOnly lock to the fetched values. See Data Sources for the full configuration reference.
Quick Start
Here is a minimal custom block — an announcement banner with a title, message, and background color:
import { init } from '@templatical/embedded';
const editor = await init({
container: '#email-editor',
auth: { url: 'https://your-app.com/api/token' },
customBlocks: [
{
type: 'announcement',
name: 'Announcement',
icon: '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m3 11 18-5v12L3 13v-2z"/><path d="M11.6 16.8a3 3 0 1 1-5.8-1.6"/></svg>',
description: 'A simple announcement banner',
fields: [
{ key: 'title', type: 'text', label: 'Title', default: 'Announcement' },
{ key: 'message', type: 'textarea', label: 'Message', default: 'Your message here' },
{ key: 'bgColor', type: 'color', label: 'Background Color', default: '#4f46e5' },
],
template: `
<div style="background: {{ bgColor }}; padding: 24px; border-radius: 8px; text-align: center;">
<h2 style="color: #ffffff; margin: 0 0 8px;">{{ title }}</h2>
<p style="color: #e0e7ff; margin: 0;">{{ message }}</p>
</div>
`,
},
],
});What happens:
- Sidebar — The "Announcement" block appears in the sidebar with your SVG icon
- Canvas — When dragged in, the block renders the Liquid template with default field values
- Toolbar — Clicking the block shows a property panel with text inputs and a color picker
- Live preview — Editing any field re-renders the template in real time
- Export — On save, the Liquid template is rendered to static HTML for email delivery
Block Definition Reference
Each custom block is defined by a CustomBlockDefinition object:
{
type: 'product-card', // Unique identifier
name: 'Product Card', // Display name in sidebar/toolbar
icon: '<svg>...</svg>', // SVG string or image URL (optional)
description: 'A product card', // Tooltip/description (optional)
fields: [ /* ... */ ], // Array of field definitions
template: '...', // Liquid template string
}| Property | Type | Required | Description |
|---|---|---|---|
type | string | Yes | Unique identifier for this block type. Must not conflict with built-in types: text, image, button, divider, spacer, social, video, html, section. |
name | string | Yes | Human-readable name shown in the sidebar and toolbar header. |
icon | string | No | SVG markup string (rendered inline) or an image URL (rendered as <img>). Falls back to a default block icon when omitted. |
description | string | No | Short description shown as a tooltip or subtitle. |
fields | array | Yes | Array of field definitions that determine the toolbar controls and template variables. |
template | string | Yes | Liquid template string rendered with the field values. Must produce email-compatible HTML (inline styles, no unsupported tags). |
Icon Examples
SVG string (recommended for crisp rendering):
icon: '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/></svg>',Image URL:
icon: 'https://your-cdn.com/icons/product-card.png',Liquid Template Syntax
Custom block templates use Liquid — a widely-used template language popularized by Shopify. Templates are processed client-side in the browser.
Variables
Insert field values with double curly braces:
{{ fieldKey }}Conditionals
Show or hide content based on field values:
{% if description %}
<p>{{ description }}</p>
{% endif %}
{% unless showPrice %}
<p>Contact us for pricing</p>
{% endunless %}
{% if layout == 'horizontal' %}
<table><tr>...</tr></table>
{% else %}
<div>...</div>
{% endif %}Loops
Iterate over repeatable fields:
{% for item in features %}
<li>{{ item.icon }} {{ item.text }}</li>
{% endfor %}Loop variables are available inside the {% for %} block:
{{ forloop.index }}— Current iteration (1-based){{ forloop.index0 }}— Current iteration (0-based){{ forloop.first }}—trueon the first iteration{{ forloop.last }}—trueon the last iteration{{ forloop.length }}— Total number of items
Filters
Transform values with pipe syntax:
{{ name | upcase }}
{{ description | truncate: 100 }}
{{ price | default: 'N/A' }}
{{ name | strip_html }}Security
Your templates are rendered without sanitization — you have full control over the HTML output. Field values entered by your users have <script> tags automatically stripped to prevent XSS injection.
Learn More
Liquid supports many more features including operators, advanced filters, and additional tags. For the full language reference, see the official Liquid documentation.
Field Types
Fields define the controls shown in the toolbar when a custom block is selected. Each field maps to a Liquid template variable.
text
Basic single-line text input.
{
key: 'name',
type: 'text',
label: 'Product Name',
default: 'Product',
placeholder: 'Enter name',
required: true,
}Template usage: {{ name }}
| Property | Type | Description |
|---|---|---|
key | string | Variable name used in the template |
label | string | Label shown above the input |
default | string | Default value when the block is created |
placeholder | string | Placeholder text in the input |
required | boolean | Shows a required indicator |
textarea
Multiline text input that auto-resizes.
{
key: 'description',
type: 'textarea',
label: 'Description',
default: '',
}Template usage: {{ description }}
image
URL input with media library integration. When clicked, opens the SDK's media library for image selection.
{
key: 'photo',
type: 'image',
label: 'Photo',
default: 'https://placehold.co/300x200',
}Template usage: <img src="{{ photo }}" alt="Photo" style="width: 100%; height: auto;" />
color
Color picker with hex text input side by side. Outputs a hex color string.
{
key: 'bgColor',
type: 'color',
label: 'Background Color',
default: '#007bff',
}Template usage: <div style="background: {{ bgColor }};">...</div>
number
Number input with optional min, max, and step constraints.
{
key: 'rating',
type: 'number',
label: 'Star Rating',
default: 5,
min: 1,
max: 5,
step: 1,
}Template usage: {{ rating }} out of 5 stars
| Property | Type | Description |
|---|---|---|
min | number | Minimum allowed value |
max | number | Maximum allowed value |
step | number | Step increment for the input |
default | number | Default value |
select
Dropdown with predefined options.
{
key: 'layout',
type: 'select',
label: 'Layout',
options: [
{ label: 'Horizontal', value: 'horizontal' },
{ label: 'Vertical', value: 'vertical' },
],
default: 'horizontal',
}Template usage: {% if layout == 'horizontal' %}...{% endif %}
| Property | Type | Description |
|---|---|---|
options | array | Array of { label, value } objects |
default | string | Default selected value |
boolean
Toggle switch that outputs true or false.
{
key: 'showPrice',
type: 'boolean',
label: 'Show Price',
default: true,
}Template usage: {% if showPrice %}<p>{{ price }}</p>{% endif %}
repeatable
An array of sub-field groups. Users can add, remove, and reorder items. Supports minItems and maxItems constraints.
{
key: 'features',
type: 'repeatable',
label: 'Features',
fields: [
{ key: 'icon', type: 'text', label: 'Icon' },
{ key: 'text', type: 'text', label: 'Feature Text' },
],
default: [{ icon: '✓', text: 'Feature 1' }],
minItems: 1,
maxItems: 5,
}Template usage:
{% for f in features %}
<li>{{ f.icon }} {{ f.text }}</li>
{% endfor %}| Property | Type | Description |
|---|---|---|
fields | array | Sub-field definitions (any type except repeatable) |
default | array | Default items as an array of objects |
minItems | number | Minimum number of items |
maxItems | number | Maximum number of items |
Repeatable Fields
Repeatable fields are useful for lists, grids, and collections. Here is a complete walkthrough.
Defining a Repeatable Field
{
key: 'items',
type: 'repeatable',
label: 'Menu Items',
fields: [
{ key: 'name', type: 'text', label: 'Item Name', required: true },
{ key: 'price', type: 'text', label: 'Price' },
{ key: 'description', type: 'textarea', label: 'Description' },
{ key: 'isVegetarian', type: 'boolean', label: 'Vegetarian', default: false },
],
default: [
{ name: 'Starter', price: '$9.99', description: 'A delicious starter', isVegetarian: false },
],
minItems: 1,
maxItems: 10,
}Template with Repeatable
<table style="width: 100%; border-collapse: collapse;">
{% for item in items %}
<tr style="border-bottom: 1px solid #eee;">
<td style="padding: 12px;">
<strong>{{ item.name }}</strong>
{% if item.isVegetarian %} 🌿{% endif %}
{% if item.description %}
<br /><span style="color: #666;">{{ item.description }}</span>
{% endif %}
</td>
<td style="padding: 12px; text-align: right; font-weight: bold;">
{{ item.price }}
</td>
</tr>
{% endfor %}
</table>Constraints
minItems— The "Remove" button is disabled when the item count equalsminItemsmaxItems— The "Add" button is disabled when the item count equalsmaxItems- Nested repeatables are not supported — a repeatable field cannot contain another repeatable field
Examples
Product Card
A product card with image, details, features list, and CTA button.
{
type: 'product-card',
name: 'Product Card',
icon: '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4Z"/><line x1="3" x2="21" y1="6" y2="6"/><path d="M16 10a4 4 0 0 1-8 0"/></svg>',
description: 'Display a product with image, name, price, and CTA',
fields: [
{ key: 'image', type: 'image', label: 'Product Image', required: true },
{ key: 'name', type: 'text', label: 'Name', default: 'Product Name' },
{ key: 'price', type: 'text', label: 'Price', default: '$0.00' },
{ key: 'description', type: 'textarea', label: 'Description', default: '' },
{ key: 'ctaText', type: 'text', label: 'Button Text', default: 'Shop Now' },
{ key: 'ctaUrl', type: 'text', label: 'Button URL', default: '#' },
{ key: 'ctaColor', type: 'color', label: 'Button Color', default: '#007bff' },
{
key: 'features',
type: 'repeatable',
label: 'Features',
fields: [
{ key: 'icon', type: 'text', label: 'Icon' },
{ key: 'text', type: 'text', label: 'Feature Text' },
],
default: [{ icon: '✓', text: 'Free shipping' }],
maxItems: 5,
},
],
template: `
<div style="border: 1px solid #e0e0e0; border-radius: 8px; overflow: hidden; font-family: Arial, sans-serif;">
<img src="{{ image }}" alt="{{ name }}" style="width: 100%; height: auto; display: block;" />
<div style="padding: 16px;">
<h3 style="margin: 0 0 8px; font-size: 18px; color: #333;">{{ name }}</h3>
{% if description %}
<p style="color: #666; margin: 0 0 8px; font-size: 14px;">{{ description }}</p>
{% endif %}
<p style="font-size: 24px; font-weight: bold; margin: 0 0 16px; color: #111;">{{ price }}</p>
{% if features %}
<ul style="list-style: none; padding: 0; margin: 0 0 16px;">
{% for f in features %}
<li style="padding: 4px 0; font-size: 14px; color: #555;">{{ f.icon }} {{ f.text }}</li>
{% endfor %}
</ul>
{% endif %}
<a href="{{ ctaUrl }}" style="background: {{ ctaColor }}; color: #ffffff; padding: 12px 24px; border-radius: 4px; text-decoration: none; display: inline-block; font-weight: bold;">{{ ctaText }}</a>
</div>
</div>
`,
}Event Card
An event card with date, venue, schedule, and RSVP button. Uses a repeatable field for the agenda, conditionals to show the venue address only when provided, and forloop.last to control separator styling.
{
type: 'event-card',
name: 'Event Card',
icon: '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect width="18" height="18" x="3" y="4" rx="2"/><line x1="16" x2="16" y1="2" y2="6"/><line x1="8" x2="8" y1="2" y2="6"/><line x1="3" x2="21" y1="10" y2="10"/></svg>',
description: 'Event details with date, venue, and RSVP',
fields: [
{ key: 'eventName', type: 'text', label: 'Event Name', default: 'Annual Conference', required: true },
{ key: 'date', type: 'text', label: 'Date', default: 'March 15, 2026' },
{ key: 'venue', type: 'text', label: 'Venue Name', default: 'Convention Center' },
{ key: 'venueAddress', type: 'text', label: 'Venue Address', default: '' },
{
key: 'schedule',
type: 'repeatable',
label: 'Schedule',
fields: [
{ key: 'time', type: 'text', label: 'Time' },
{ key: 'title', type: 'text', label: 'Session Title' },
],
default: [
{ time: '9:00 AM', title: 'Registration & Coffee' },
{ time: '10:00 AM', title: 'Keynote' },
{ time: '12:00 PM', title: 'Lunch Break' },
],
minItems: 1,
maxItems: 10,
},
{ key: 'rsvpUrl', type: 'text', label: 'RSVP URL', default: '#' },
{ key: 'accentColor', type: 'color', label: 'Accent Color', default: '#6366f1' },
],
template: `
<div style="border: 2px solid {{ accentColor }}; border-radius: 8px; overflow: hidden; font-family: Arial, sans-serif;">
<div style="background: {{ accentColor }}; padding: 16px; text-align: center;">
<h2 style="margin: 0; color: #ffffff; font-size: 20px;">{{ eventName }}</h2>
<p style="margin: 4px 0 0; color: #e0e7ff; font-size: 14px;">{{ date }}{% if venue %} · {{ venue }}{% endif %}</p>
</div>
<div style="padding: 16px;">
{% if venueAddress %}
<p style="color: #888; font-size: 13px; margin: 0 0 16px;">{{ venueAddress }}</p>
{% endif %}
{% if schedule %}
<table style="width: 100%; border-collapse: collapse;">
{% for item in schedule %}
<tr style="{% unless forloop.last %}border-bottom: 1px solid #eee;{% endunless %}">
<td style="padding: 8px 0; color: {{ accentColor }}; font-size: 13px; font-weight: bold; width: 90px;">{{ item.time }}</td>
<td style="padding: 8px 0; font-size: 14px; color: #333;">{{ item.title }}</td>
</tr>
{% endfor %}
</table>
{% endif %}
<div style="text-align: center; padding-top: 16px;">
<a href="{{ rsvpUrl }}" style="background: {{ accentColor }}; color: #ffffff; padding: 12px 32px; border-radius: 4px; text-decoration: none; display: inline-block; font-weight: bold;">RSVP Now</a>
</div>
</div>
</div>
`,
}Testimonial
A testimonial with quote, author details, avatar, and star rating. Uses the number field with min/max and conditional rendering for the stars.
{
type: 'testimonial',
name: 'Testimonial',
icon: '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 21c3 0 7-1 7-8V5c0-1.25-.756-2.017-2-2H4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2 1 0 1 0 1 1v1c0 1-1 2-2 2s-1 .008-1 1.031V21z"/><path d="M15 21c3 0 7-1 7-8V5c0-1.25-.757-2.017-2-2h-4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2h.75c0 2.25.25 4-2.75 4v3c0 1 0 1 1 1z"/></svg>',
description: 'Customer testimonial with rating',
fields: [
{ key: 'quote', type: 'textarea', label: 'Quote', default: 'This product changed our workflow completely.', required: true },
{ key: 'authorName', type: 'text', label: 'Author Name', default: 'Jane Smith' },
{ key: 'authorTitle', type: 'text', label: 'Author Title', default: 'CEO, Acme Corp' },
{ key: 'avatar', type: 'image', label: 'Avatar', default: 'https://placehold.co/64x64' },
{ key: 'rating', type: 'number', label: 'Star Rating', default: 5, min: 1, max: 5, step: 1 },
{ key: 'showRating', type: 'boolean', label: 'Show Star Rating', default: true },
],
template: `
<div style="background: #f9fafb; border-radius: 8px; padding: 24px; font-family: Arial, sans-serif;">
{% if showRating %}
<div style="margin-bottom: 12px; font-size: 20px;">
{% if rating >= 1 %}★{% else %}☆{% endif %}
{% if rating >= 2 %}★{% else %}☆{% endif %}
{% if rating >= 3 %}★{% else %}☆{% endif %}
{% if rating >= 4 %}★{% else %}☆{% endif %}
{% if rating >= 5 %}★{% else %}☆{% endif %}
</div>
{% endif %}
<p style="font-size: 16px; line-height: 1.6; color: #374151; margin: 0 0 16px; font-style: italic;">"{{ quote }}"</p>
<table>
<tr>
<td style="vertical-align: middle; padding-right: 12px;">
<img src="{{ avatar }}" alt="{{ authorName }}" style="width: 48px; height: 48px; border-radius: 50%;" />
</td>
<td style="vertical-align: middle;">
<strong style="color: #111827; font-size: 14px;">{{ authorName }}</strong>
{% if authorTitle %}
<br /><span style="color: #6b7280; font-size: 13px;">{{ authorTitle }}</span>
{% endif %}
</td>
</tr>
</table>
</div>
`,
}Saving and Updating
When your user saves a template, each custom block's Liquid template is rendered to static HTML with the current field values. The exported HTML contains the final rendered output — ready for email delivery.
The saved template also preserves the block's field values, so blocks remain fully editable when the template is loaded again. If you change your customBlocks configuration between sessions, blocks whose definitions are no longer registered show a "Definition not found" placeholder — but the data is preserved. Re-adding the block definition restores editing.
Email Compatibility Tips
Since custom block HTML is included in the final email output, follow these guidelines for best email client compatibility:
- Use inline styles for all styling (no
<style>blocks) - Use tables for layout instead of flexbox or grid
- Avoid unsupported tags like
<div>nesting in some clients — test with your target email clients - Keep images under reasonable sizes and always set
widthandheight
Data Sources
Plan Feature
This feature is only available on the Scale plan.
Data sources let you connect a custom block to external content — products, articles, events, or any data from your application. Instead of filling in every field by hand, your users pick an item through your own UI and the fields populate automatically.
A data source is optional on any custom block definition. When present, the block renders with a call-to-action overlay prompting the user to fetch content. After fetching, fields marked readOnly lock to the returned values while other fields remain editable.
How It Works
- Block added — The block renders with default field values and a CTA overlay
- User clicks "Load content" — Your
onFetchcallback fires, opening your own picker UI - Data returned — The SDK maps returned keys to matching field keys and populates the values
- Read-only fields lock — Fields with
readOnly: truebecome disabled with a lock icon - Editable fields stay open — Fields without
readOnly(e.g., button text) remain editable - "Change" button — After the initial fetch, a "Change" button in the toolbar lets the user re-fetch
Configuration
Add a dataSource object to your block definition:
{
type: 'product-card',
name: 'Product Card',
fields: [ /* ... */ ],
template: '...',
dataSource: {
label: 'Pick a product',
onFetch: async ({ fieldValues, blockId }) => {
const product = await showProductPicker();
if (!product) return null;
return { name: product.title, price: product.price };
},
},
}| Property | Type | Required | Description |
|---|---|---|---|
label | string | Yes | Text shown on the fetch button and CTA overlay |
onFetch | function | Yes | Async callback that returns field data or null to cancel |
The onFetch Callback
The onFetch callback receives a context object and must return either a data object or null:
interface DataSourceFetchContext {
fieldValues: Record<string, unknown>;
blockId: string;
}
interface DataSourceConfig {
label: string;
onFetch: (context: DataSourceFetchContext) => Promise<Record<string, unknown> | null>;
}fieldValues— A copy of the block's current field values, useful if your picker needs contextblockId— The unique block instance ID- Return value — An object whose keys match field keys. Only matching keys are mapped; extra keys are ignored
- Return
null— Cancels the fetch. No fields are changed and the block stays as-is
The readOnly Field Property
Add readOnly: true to any field definition to lock it after a data source fetch:
fields: [
{ key: 'name', type: 'text', label: 'Name', default: 'Product', readOnly: true },
{ key: 'price', type: 'text', label: 'Price', default: '$0.00', readOnly: true },
{ key: 'ctaText', type: 'text', label: 'Button Text', default: 'Shop Now' },
]Read-only behavior:
- Only enforced when the block has a
dataSourceand data has been fetched - Before the first fetch, all fields are editable so users can set defaults
- When active, the field input is disabled with reduced opacity, a lock icon, and a tooltip
- Works with all field types including
repeatable(disables add/remove and locks nested fields)
INFO
Note: Using readOnly on a field without a dataSource on the block definition has no effect. The SDK logs a warning to the console in this case to help catch configuration mistakes.
Error Handling
- If
onFetchreturnsnull, the operation is treated as cancelled — no fields are changed - If
onFetchthrows an exception, the error is caught and logged to the console with a[Templatical]prefix. The block stays in its current state
Complete Example
A product card that fetches product data from your application:
customBlocks: [
{
type: 'product-card',
name: 'Product Card',
fields: [
{ key: 'name', type: 'text', label: 'Name', default: 'Product', readOnly: true },
{ key: 'price', type: 'text', label: 'Price', default: '$0.00', readOnly: true },
{ key: 'image', type: 'image', label: 'Image', readOnly: true },
{ key: 'ctaText', type: 'text', label: 'Button Text', default: 'Shop Now' },
],
template: `
<div style="border: 1px solid #e0e0e0; border-radius: 8px; overflow: hidden; font-family: Arial, sans-serif;">
<img src="{{ image }}" alt="{{ name }}" style="width: 100%; height: auto; display: block;" />
<div style="padding: 16px;">
<h3 style="margin: 0 0 8px; font-size: 18px; color: #333;">{{ name }}</h3>
<p style="font-size: 24px; font-weight: bold; margin: 0 0 16px; color: #111;">{{ price }}</p>
<a href="#" style="background: #007bff; color: #ffffff; padding: 12px 24px; border-radius: 4px; text-decoration: none; display: inline-block; font-weight: bold;">{{ ctaText }}</a>
</div>
</div>
`,
dataSource: {
label: 'Pick a product',
onFetch: async ({ fieldValues, blockId }) => {
// Open your own product picker UI
const product = await showProductPicker();
// Return null to cancel
if (!product) return null;
// Return an object with keys matching your field keys
return {
name: product.title,
price: `$${product.price.toFixed(2)}`,
image: product.imageUrl,
};
},
},
},
]In this example:
name,price, andimagearereadOnly— they lock after fetching product datactaTextis notreadOnly— the user can customize the button text even after fetching- The
onFetchcallback opens a product picker, and the returned keys (name,price,image) are automatically mapped to the matching fields
Troubleshooting
The SDK validates your block definitions at init time and logs warnings to the browser console if anything looks wrong — for example, duplicate type values, field key conflicts, or an empty template. Invalid definitions are skipped while valid ones still register normally.
If a block shows a "Definition not found" placeholder, the block was saved with a type that is not in your current customBlocks configuration. Make sure you pass the same block definitions each time you initialize the editor.
If a block shows a red error, there is a syntax error in your Liquid template. Check the browser console for the specific error message and fix the template string.