@maildeno/renderer turns a template JSON document into HTML, MJML, or React Email — locally, through an embedded WebAssembly engine. No API calls, no API key, no network access. The same import works unchanged in Node, in a browser, in a Cloudflare Worker, in Vercel Edge, and in Deno, and the output is byte-identical everywhere because it is the same compiled engine everywhere.
MIT v0.2.0 Zero dependencies
| This is the open-source, offline renderer. If you are looking for the client that talks to Maildeno’s hosted Render API, see the JavaScript/TypeScript SDK instead. Open Source explains how the two relate and how to combine them. |
At a glance
| Property | Value |
|---|---|
Package |
|
License |
MIT |
Runtime dependencies |
None. Not "few" — zero. Nothing transitive to audit. |
Network calls |
None, ever. There is no code path that opens a socket. |
Engine |
Rust compiled to WebAssembly, embedded in the package |
Node requirement |
Node 20+ |
Other runtimes |
Browsers, Cloudflare Workers, Vercel Edge, Deno |
Output formats |
HTML, MJML, React Email ( |
Bundle size |
~9 KB in Node · ~90 KB brotli at the edge (Wasm inlined) |
Installation
npm install @maildeno/renderer
That is the entire install for every runtime. There is no separate edge package, no bundler plugin, and no engine.wasm asset to deploy alongside your code — see Runtimes for how the right build is selected.
Your first render
import { render } from "@maildeno/renderer"
const html = await render("templates/welcome.json", {
mergeTags: { text: { first_name: "Ada" } },
context: { plan: "premium" },
})
A path in, a string out. That is the whole API for the common case.
In a browser or an edge runtime there is no filesystem, so you pass an already-parsed template object instead of a path. Everything else is identical — see Runtimes.
Getting a template
Every one of these produces the same format, and the renderer does not care which you used:
| Source | How |
|---|---|
Open-source editor |
|
Hosted dashboard |
Exporting panel → JSON. |
REST API |
|
Your own code |
Generate the document programmatically. See Template schema for the contract. |
Name the file whatever suits you — welcome.json, or the template’s UUID.
A template is a plain JSON object with five top-level keys:
{
"template_id": "welcome_to_premium",
"template_name": "Welcome to Premium",
"canvas": { "backgroundColor": "#ffffff", "width": 600 },
"rows": [],
"schema_version": "1.0"
}
| Templates are data. Store the JSON in your database and re-render it whenever you need output, rather than storing the rendered HTML. HTML is a build artifact — you will want to regenerate it with a newer engine one day. |
The five render functions
| Function | Signature | Returns |
|---|---|---|
|
|
Output for |
|
|
HTML string |
|
|
MJML source |
|
|
React Email |
|
|
|
The three target-specific helpers are render with the target fixed. Their options type omits target, so the type system rejects renderHtml(path, { target: "mjml" }) rather than silently ignoring it.
render returns the string rather than a result object so it composes straight into a template literal or an ESP call with nothing to unwrap. Reach for renderToResult when you also want the template’s identity:
import { renderToResult } from "@maildeno/renderer"
const { output, templateId, templateName, target } =
await renderToResult("welcome.json", { target: "mjml" })
console.log(`Rendered "${templateName}" (${templateId}) as ${target}`)
TemplateSource
Every function accepts either form:
type TemplateSource = string | Template
// A path — read and parsed for you. Node only.
await render("templates/welcome.json")
// An already-parsed template — no file I/O at all.
const template = await db.templates.findById(id)
await render(template)
The object form is what you want when templates live in a database, are generated in memory, or are imported by a bundler. It is also the only form available in browsers and edge runtimes.
Both forms are validated identically. Skipping validation for in-memory templates would make failures depend on how the template arrived rather than on whether it is correct.
Options
| Option | Type | Default | Description |
|---|---|---|---|
|
|
|
Output format. |
|
|
— |
Values substituted into the template. See Merge tags. |
|
|
— |
Values that visibility rules are evaluated against. See Context and conditional content. |
|
|
|
Collapse redundant whitespace. See Minification. |
|
|
|
Directory relative paths resolve against, and a security boundary. Node only — ignored in the browser/edge build, which never resolves a path. See Paths and the |
Merge tags
Tag names in the template must be group-qualified — {{ text.first_name }}, not {{ first_name }}. The prefix tells the engine how to escape the substituted value, and an unprefixed tag will not resolve at all.
await render("welcome.json", {
mergeTags: {
text: { first_name: "Ada", plan: "Premium" }, // visible text
url: { cta: "https://app.example.com/start" }, // href / src
attr: { hero_alt: "Product screenshot" }, // attribute values
},
})
| Group | Substituted into | Escaping |
|---|---|---|
|
Paragraphs, headings, buttons, list items |
HTML-escaped |
|
|
URL-encoded |
|
HTML attribute values |
HTML-escaped |
The grouping is functional, not cosmetic. A URL placed in text is HTML-escaped rather than URL-encoded, and will break for any value containing &:
// Wrong — the & becomes & and the link loses its second parameter
mergeTags: { text: { cta: "https://x.com/a?b=1&c=2" } }
// Right
mergeTags: { url: { cta: "https://x.com/a?b=1&c=2" } }
| A tag with no supplied value is removed, not left visible. A typo in a tag name disappears silently rather than showing up in a test send. When a value must be present, assert on the rendered output in your tests rather than trusting a visual check. |
const html = await render(tpl, { mergeTags: { text: { first_name: name } } })
// Cheap insurance against a renamed or misspelled tag
if (!html.includes(name)) {
throw new Error("first_name did not render — check the tag name in the template")
}
Context and conditional content
Rows and blocks can carry visibility rules set in the editor. context supplies the values those rules are evaluated against:
await render("welcome.json", {
context: { plan: "premium", country: "NG", is_trial: false },
})
Content whose conditions do not match is omitted from the output entirely — the rendered email contains only what that recipient should see. This is smaller and simpler than shipping every branch, and it means your ESP does not need to support conditional syntax at all.
Context values are never injected into content. They only decide what is shown.
|
There are two places conditional content can be resolved, and it is worth choosing deliberately rather than by accident:
Use prune when the personalisation data lives in your database; use wrap when it lives in the ESP. The renderer only does prune, because which syntax to emit depends on which ESP you selected in the editor. |
Minification
minify defaults to true and collapses redundant whitespace — indentation, blank lines, and runs of spaces.
It deliberately does not:
-
strip or alter HTML, CSS or JS comments
-
remove, add, or change attribute quoting
-
rewrite attribute values
-
touch CSS property values, including media queries
-
remove any tag or content
That first exclusion matters more than it looks. <!--[if mso]> is a comment that changes rendering — it is how Outlook fallbacks are delivered. A general-purpose HTML minifier that strips comments will silently break every Outlook fallback in your email. Check before running one over this output.
|
React Email is handled differently from markup: per-line trimming, three-or-more blank lines collapsed to one, then trimmed. It is never collapsed onto a single line, because it is source code someone will read.
Paths and the baseDir boundary
This section describes the Node build. In browsers and edge runtimes there is no filesystem, so source must always be an already-parsed template.
Relative paths resolve against baseDir, which defaults to the process working directory. Absolute paths are always honoured as given — a caller writing an absolute path has stated their intent, and second-guessing it would break legitimate use.
await render("welcome.json", { baseDir: "/srv/app/templates" })
await render("/srv/app/templates/welcome.json")
|
If a template name ever comes from user input, set
The check compares resolved paths rather than scanning the input for |
A missing-file error names the resolved path, not the input, since the useful question is usually which directory was searched.
Runtimes
The same import works unchanged everywhere. Your bundler picks the right build automatically through `package.json’s conditional exports — there is nothing to configure.
import { render } from "@maildeno/renderer"
| Runtime | Selected by | Accepts a path? |
|---|---|---|
Node 20+ |
the |
Yes |
Cloudflare Workers |
the |
No |
Vercel Edge Runtime |
the |
No |
Browsers, via a bundler |
the |
No |
Deno |
the |
No |
Anything else |
falls back to the browser/edge build |
No |
Node gets a build that reads engine.wasm from disk. Everywhere else gets a build with the engine embedded as a base64 string — so it is still one npm install, still zero network calls, and still nothing to deploy as a separate asset.
The neutral fallback is the edge build deliberately: it makes no assumptions about what is available, which is the conservative default.
The one behavioural difference
// Node — both work
await render("templates/welcome.json")
await render(templateObject)
// Browser / Workers / Vercel Edge / Deno — only this
await render(templateObject)
Read the template however makes sense for your runtime — fetch(), a KV/R2/Durable Object binding, a bundler JSON import — and pass the parsed object in.
A string source throws RenderError with code TEMPLATE_NOT_FOUND and a message explaining what to do instead, rather than failing with something opaque like "fs is not defined". Silently trying fetch() on a bare string would reintroduce the network dependency this package is explicit about not having, and would be a surprising thing for a "path" argument to do.
Every other option and every error code behaves identically.
| For a complete, deployable Cloudflare Workers example — template in KV, rendered at the edge, sent through an ESP — see Rendering on Cloudflare Workers. |
Bundle size at the edge
Embedding the engine as base64 costs about 33% over the raw file, working out to roughly 90 KB brotli-compressed in your build. Worth comparing against Cloudflare’s multi-megabyte Worker size limits before assuming it is a problem — it rarely is the constraint.
If it genuinely is, and your bundler can hand you a compiled Wasm module more directly, @maildeno/renderer/core skips the embedded copy and takes an instance you supply:
import mod from "@maildeno/renderer/engine.wasm" // resolved by Wrangler
import { renderWithInstance } from "@maildeno/renderer/core"
const instance = await WebAssembly.instantiate(mod, {})
const html = await renderWithInstance(instance, templateObject)
Validation, merge tags, context and minification all behave exactly as they do in render() — this only changes where the instance comes from. There is no baseDir, for the same reason as the edge build.
This is a niche optimisation most deployments will not need. Reach for render first. The import shape above follows Wrangler’s own documented .wasm-import behaviour; smoke-test it in your deployment before relying on it, the way you would for any bundler-specific import.
|
Errors
Everything throws RenderError, which carries a machine-readable code:
import { render, RenderError } from "@maildeno/renderer"
try {
const html = await render("welcome.json")
} catch (err) {
if (err instanceof RenderError) {
switch (err.code) {
case "TEMPLATE_NOT_FOUND": /* missing file, or a path at the edge */ break
case "INVALID_TEMPLATE": /* bad JSON or wrong shape */ break
case "RENDER_ERROR": /* the engine itself failed */ break
}
}
throw err
}
| Code | Raised when |
|---|---|
|
The file is missing or unreadable; the path resolves outside |
|
The content is not valid JSON, is not an object, is missing a required field, has a field of the wrong type, or declares a |
|
|
Templates are validated before rendering, so missing fields and wrong types are reported by name rather than surfacing as an opaque failure from inside compiled Rust:
Template from /srv/app/templates/welcome.json is missing required
field(s): canvas, rows. Expected a Maildeno template export
({ template_id, template_name, canvas, rows, schema_version }).
In practice
import { render } from "@maildeno/renderer"
import { Resend } from "resend"
const resend = new Resend(process.env.RESEND_API_KEY)
export async function sendWelcome(user: User) {
const html = await render("templates/welcome.json", {
baseDir: process.env.TEMPLATE_DIR,
mergeTags: {
text: { first_name: user.firstName, plan: user.plan },
url: { cta: `https://app.example.com/onboarding?u=${user.id}` },
},
context: { plan: user.plan, is_trial: user.isTrial },
})
await resend.emails.send({
from: "hello@example.com",
to: user.email,
subject: `Welcome, ${user.firstName}`,
html,
})
}
Rendering is local and synchronous in practice — no rate limits, no timeouts, and nothing to mock in tests. Rendering per recipient inside a loop is fine. If you are batching to avoid render cost, you are optimising the wrong thing: it is a Wasm function call over a few kilobytes of JSON.
Testing rendered output
Because rendering is a pure function with a large deterministic output, snapshot tests are an unusually good fit:
import { expect, test } from "vitest"
import { render } from "@maildeno/renderer"
test("welcome email renders", async () => {
const html = await render("templates/welcome.json", {
mergeTags: { text: { first_name: "Ada" } },
context: { plan: "premium" },
})
expect(html).toMatchSnapshot()
})
Run this on every pull request and a copy change that breaks a layout shows up in code review rather than in a customer’s inbox.
Security posture
| Property | Detail |
|---|---|
No network |
Neither build makes an outbound request, ever. Nothing to firewall, nothing to allow-list. |
No credentials |
No API key exists anywhere in the surface area. |
No dependencies |
Zero runtime dependencies. Nothing transitive to audit. |
Path containment |
|
Validated input |
Loading and validation are not exported. There is no way to reach the engine with an unvalidated document. |
Escaping by declaration |
Escaping is decided by a tag’s group, not guessed from its value. |
Memory ceiling |
The engine heap is capped and monitored per render. |
This combination is the reason the package tends to clear a vendor review quickly: there is no service to assess, no data flow to diagram, and no dependency tree to scan.
How this differs from the hosted SDK
Both render Maildeno templates in-process through the same engine, but they solve different problems:
@maildeno/renderer |
maildeno (hosted SDK) |
|
|---|---|---|
API key |
Not required |
Required |
Network access |
None — ever |
One fetch per template per TTL window, then cached and rendered in-process |
Input |
A template JSON file or in-memory object |
A template ID — the SDK fetches the JSON for you |
Runtimes |
Node, browsers, Workers, Vercel Edge, Deno |
Node and other server runtimes |
Caching |
Not applicable — nothing to cache |
Memory and disk caches, stale-on-error fallback |
Best for |
CI, air-gapped environments, edge deployments, or any setup where the template already exists as a file |
Apps where templates are managed in the hosted dashboard and referenced by ID at runtime |
They are not mutually exclusive — see Mix and match.
Migrating from the maildeno SDK
// Before — fetched over the network
const client = new MaildenoClient({ apiKey: process.env.MAILDENO_API_KEY })
const { output } = await client.render({
templateId: "welcome",
target: "html",
dynamicData: { merge_tags: { text: { name: "Noruwa" } } },
})
// After — local file
const output = await render("templates/welcome.json", {
mergeTags: { text: { name: "Noruwa" } },
})
dynamicData: { merge_tags, context } becomes mergeTags and context at the top level.
Removed with no replacement, because none of it applies to local files: apiKey, baseUrl, timeout, all caching (cache, listCached, deleteCached, clearCache, invalidate), fromStaleCache, and the network error codes INVALID_API_KEY, FORBIDDEN, NETWORK_ERROR and TIMEOUT.
Where to go next
-
Browsers and edge runtimes — the full runtime guide
-
Rendering on Cloudflare Workers — a complete deployable example
-
Template schema — the JSON contract, field by field
-
@maildeno/editor— produce the template JSON this package renders