One JSON document is the interface between every part of Maildeno. The editor produces it, the renderer consumes it, the hosted API serves it, and all three validate it. This page is the contract.
Schema 1.0
The shape
{
"template_id": "welcome_to_premium",
"template_name": "Welcome to Premium",
"canvas": { "backgroundColor": "#ffffff", "width": 600 },
"rows": [],
"schema_version": "1.0"
}
Five top-level keys. All five are required, and validation reports every missing one together rather than the first:
Template from welcome.json is missing required field(s): canvas, rows.
Expected a Maildeno template export
({ template_id, template_name, canvas, rows, schema_version }).
| Field | Type | Description |
|---|---|---|
|
|
Yours to choose. A UUID from the hosted dashboard, a slug, or anything else. |
|
|
Human-readable label. Shown in the editor and echoed by |
|
|
The email body’s own settings — width, padding, background, preheader, language. Not an HTML |
|
|
Ordered row definitions. The document tree. |
|
|
|
Schema versioning
Only the major is compared. 1.7 renders under any 1.x renderer; 2.0 is rejected with a message telling you to upgrade.
A minor bump means additive, backward-compatible changes. Rejecting those would break templates that render perfectly well.
Template from welcome.json: schema_version "2.0" is newer than this
renderer supports (1.x). Upgrade @maildeno/renderer.
The canvas object
Email-body settings. Every key has a default, so a minimal canvas is {}.
| Key | Type | Default | Description |
|---|---|---|---|
|
|
|
Body width in pixels. 600 is the long-standing safe maximum for email. |
|
|
|
The email body’s own background. |
|
|
|
The area around the body, visible in clients that show it. |
|
|
|
Optional background image URL. |
|
|
|
CSS |
|
|
|
CSS |
|
|
|
CSS |
|
|
|
Body padding in pixels. |
|
|
|
The grey line after the subject in the inbox. |
|
|
|
Sets |
|
|
|
Width below which mobile overrides apply. |
preheaderText is the most under-used 90 characters in email. If you leave it at the default, recipients see "View this email in your browser" in their inbox preview instead of something useful.
|
The node tree
Template
└── rows: Row[]
└── columns: Column[]
└── children: (Component | NestedRow | RowSpacer)[]
└── Component { id, type, componentType?, props }
Nesting is capped at depth 5, enforced both by the editor’s canvas guard and by the export engine. Past that depth, nested-table layout starts producing gaps in Outlook that nobody can explain.
A worked example
{
"template_id": "welcome",
"template_name": "Welcome",
"canvas": { "backgroundColor": "#ffffff", "width": 600 },
"schema_version": "1.0",
"rows": [
{
"id": "row-1",
"columns": [
{
"id": "col-1",
"width": 12,
"children": [
{
"id": "c-1",
"type": "heading",
"props": {
"content": "Welcome, {{ text.first_name }}!",
"level": 1
}
},
{
"id": "c-2",
"type": "paragraph",
"props": { "content": "Thanks for upgrading." }
}
]
}
]
}
]
}
Two column shapes
Columns support two shapes. The current one uses children; an older one uses components. Every consumer reads:
const items = column.children ?? column.components ?? []
Likewise a component’s type is read as component.componentType ?? component.type.
| Do not remove either fallback in code that consumes this format. Templates saved by earlier editor versions rely on both. |
Built-in component types
type |
Renders as |
|---|---|
|
Rich-text body copy |
|
|
|
|
|
Thumbnail with a play overlay — email clients do not play video |
|
Ordered or unordered list |
|
A styled |
|
Inline text link |
|
Horizontal rule |
|
Fixed vertical space |
|
Horizontal navigation row |
|
Social icon row |
Your own types registered with registerBlock() appear here too and serialise the same way. See Custom blocks.
Merge tags in content
Merge tags are group-qualified tokens embedded in string props:
{ "content": "Welcome, {{ text.first_name }}!" }
{ "href": "{{ url.cta }}" }
{ "alt": "{{ attr.hero_alt }}" }
| Group | Substituted into | Escaping |
|---|---|---|
|
Paragraphs, headings, buttons, list items |
HTML-escaped |
|
|
URL-encoded |
|
HTML attribute values |
HTML-escaped |
The prefix is not decoration. An unprefixed {{ first_name }} will not resolve, and a URL placed in the text group is HTML-escaped rather than URL-encoded — which breaks any value containing &.
|
A pipe default supplies a fallback when no value is provided:
{{ text.first_name|'there' }}
This matters most in image alt text. An unguarded alt="{{ attr.product_name }}" with no value resolves to alt="", which tells assistive technology the image is decorative. It is not.
Visibility rules
Rows and components can carry a visibility object evaluated against the context passed at render time:
{
"id": "row-2",
"visibility": {
"enabled": true,
"match": "all",
"rules": [
{ "tag": "plan", "operator": "equals", "value": "premium" },
{ "tag": "is_trial", "operator": "not_equals", "value": "true" }
]
},
"columns": []
}
| Key | Meaning |
|---|---|
|
Whether the rule set is active. |
|
|
|
The conditions. Each names a context key, an operator, and a value. |
Context values are never injected into content. They only decide what is shown. See Visibility rules for the operator reference.
Optimize and hydrate
Export does not write the in-memory tree verbatim. Two paired operations bracket it:
-
optimize — strips every property equal to its default before serialising. A row with no background, no border and default padding serialises to almost nothing.
-
hydrate — restores every stripped default on import.
The invariant is hydrate(optimize(x)) ≡ x.
|
If you fork the editor and add a property with a default value, you must touch both operations. Adding it to one only means the property silently resets on reload — a bug that is very hard to trace back to its cause. |
The practical consequence for you: a template file is much smaller than the in-memory document, and a hand-written template only needs the fields that differ from the defaults.
Validating a template yourself
The renderer validates before every render, so you rarely need to. If you are accepting uploaded templates, these are the checks it performs, in order:
-
is an object — not
null, not an array -
all five required fields are present
-
template_idis a string -
template_nameis a string -
rowsis an array -
canvasis an object -
schema_versionis a string -
the major parses as a number
-
the major is not newer than the renderer supports
import { render, RenderError } from "@maildeno/renderer"
try {
await render(uploaded, { mergeTags: {}, context: {} })
} catch (err) {
if (err instanceof RenderError && err.code === "INVALID_TEMPLATE") {
// err.message names the offending field
return res.status(400).json({ error: err.message })
}
throw err
}
Loading and validation are deliberately not exported from the renderer. Every render path already runs them, so there is no way to reach the engine with an unvalidated document.
Where to go next
-
@maildeno/renderer— render this document -
@maildeno/editor— produce it -
Merge tags — the authoring side
-
Visibility rules — the operator reference