npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@maildeno/renderer

v0.2.0

Published

Render Maildeno email templates locally to HTML, MJML or React Email — in Node, browsers, and edge runtimes like Cloudflare Workers and Vercel Edge. No API calls, no network.

Readme

@maildeno/renderer

Render Maildeno email templates to HTML, MJML or React Email — locally. No API calls, no API key, no network. Works the same way in Node, in a browser, and in edge runtimes like Cloudflare Workers and Vercel Edge.

Rendering runs in an embedded WebAssembly engine, so output is byte-identical to Maildeno's hosted renderer — the same engine everywhere, whether that's engine.wasm read from disk in Node or the same bytes embedded in the browser/edge build.

npm install @maildeno/renderer
import { render } from "@maildeno/renderer";

const html = await render("templates/welcome.json", {
  mergeTags: { text: { first_name: "Noruwa" } },
  context: { plan: "premium" },
});

That's the whole API for the common case: a path in, a string out. (In a browser or edge runtime, pass an already-parsed template instead of a path — see Browsers and edge runtimes.)


Templates

Export a template from the Maildeno editor (Export → JSON) and save the file. No conversion needed — the editor's export format is exactly what the renderer consumes:

{
  "template_id": "welcome_to_premium",
  "template_name": "Welcome to Premium",
  "canvas": { },
  "rows": [ ],
  "schema_version": "1.0"
}

Name the file whatever suits you — welcome.json, or the template's UUID.

Rendering

import {
  render,          // → string (HTML by default)
  renderHtml,
  renderMjml,
  renderReactEmail,
  renderToResult,  // → { output, templateId, templateName, target }
} from "@maildeno/renderer";

await render("welcome.json");                        // HTML
await renderMjml("welcome.json");                    // MJML
await renderReactEmail("welcome.json");              // React Email .tsx
await render("welcome.json", { target: "mjml" });    // same, dynamic target

const { output, templateName } = await renderToResult("welcome.json");

Every function accepts either a path or an already-parsed template:

const template = await db.templates.findById(id);   // straight from your DB
const html = await render(template);                 // no file I/O

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 value, and an unprefixed tag will not resolve.

await render("welcome.json", {
  mergeTags: {
    text: { first_name: "Ada", plan: "Premium" },   // visible text
    url:  { cta: "https://app.example.com/start" }, // href / src — URL-encoded
    attr: { hero_alt: "Product screenshot" },       // attributes — HTML-escaped
  },
});

| Group | Substituted into | Escaping | | --- | --- | --- | | text | Paragraphs, headings, buttons, list items | HTML-escaped | | url | href, src | URL-encoded | | attr | HTML attribute values | HTML-escaped |

The grouping is not cosmetic. A URL placed in text is HTML-escaped rather than URL-encoded, and will break for any value containing &.

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, so it's worth asserting on rendered output in tests when a value must be present.

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 don't match is omitted from the output entirely — the rendered email contains only what that recipient should see.

Options

| Option | Default | Purpose | | --- | --- | --- | | target | "html" | "html", "mjml" or "react-email" | | mergeTags | — | { text?, url?, attr? } | | context | — | Values for visibility rules | | minify | true | Collapse redundant whitespace. Structure, comments and attribute quoting are untouched. | | baseDir | process.cwd() | Directory relative paths resolve against. Node only — ignored (and irrelevant) in the browser/edge build, which never resolves a path in the first place. See Browsers and edge runtimes. |

Paths

(This section describes the Node build. In browsers and edge runtimes there is no file system, so source must always be an already-parsed template — see Browsers and edge runtimes.)

Relative paths resolve against baseDir, which defaults to the process working directory. Absolute paths are always honoured.

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 baseDir. It acts as a boundary — paths resolving outside that directory are rejected.

await render("../outside-template.json", {
  baseDir: "/srv/app/templates",
});

The check compares resolved paths rather than scanning for .., so encoded traversal and symlinks are covered too.

Browsers and edge runtimes

The same import works unchanged in Node, browsers, Cloudflare Workers, Vercel Edge Middleware/Functions, and Deno — no separate package to install, no bundler configuration to write:

import { render } from "@maildeno/renderer";

Your bundler picks the right build automatically via package.json's conditional exports. Node gets a build that reads engine.wasm from disk, exactly as before. Everywhere else gets a build with engine.wasm embedded as a base64 string — so it's still one npm install, still zero network calls, still nothing to deploy alongside it as a separate asset.

The one behavioural difference: the browser/edge build only accepts an already-parsed template, not a path — there's no file system to read a path from.

// Node: both of these work
await render("templates/welcome.json");
await render(templateObject);

// Browser / Cloudflare Workers / Vercel Edge: only this works
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 telling you what to do instead, rather than failing with something like "fs is not defined".

// Cloudflare Worker
export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const template = await env.TEMPLATES.get("welcome", "json"); // KV binding
    const html = await render(template, {
      mergeTags: { text: { first_name: "Ada" } },
    });
    return new Response(html, { headers: { "content-type": "text/html" } });
  },
};

Every other option, and every error code, behaves identically to the Node build.

Supported runtimes

| Runtime | How it's selected | | --- | --- | | Node 20+ | the node export condition | | Cloudflare Workers | the workerd condition — Wrangler sets this automatically, no config needed | | Vercel Edge Runtime | the edge-light condition | | Browsers, via a bundler | the browser condition | | Deno | the deno condition | | Anything else / a fully neutral bundler | falls back to the browser/edge build — the conservative default, since it makes no assumptions about what's available |

Bundle size

Embedding engine.wasm as base64 adds about 90 KB brotli-compressed to your build (worth comparing against, say, Cloudflare's multi-MB Worker size limits — this is rarely the constraint). If it ever is, and your bundler can hand you a compiled Wasm module more directly — for example Wrangler's native .wasm import, which uploads it as a separate module instead of inlining it — @maildeno/renderer/core skips the embedded copy and takes an instance you supply instead:

import mod from "@maildeno/renderer/engine.wasm"; // resolved by Wrangler to a WebAssembly.Module
import { renderWithInstance } from "@maildeno/renderer/core";

const instance = await WebAssembly.instantiate(mod, {});
const html = await renderWithInstance(instance, templateObject);

This is a niche optimisation most deployments won't need — reach for render first, and only look at renderWithInstance if bundle size actually becomes a problem. (This shape follows Wrangler's own documented .wasm-import behaviour; worth a quick smoke test in your own deployment before relying on it, the way you would for any bundler-specific import.)

Full examples

examples/ has complete, runnable Workers/Edge Function/Node code — one folder per runtime, one file per email provider (Resend, Postmark, Amazon SES) — including the ESP-specific parts this README doesn't cover, like signing requests to SES from a runtime the AWS SDK doesn't support well.

Errors

Everything throws RenderError with a code:

import { render, RenderError } from "@maildeno/renderer";

try {
  const html = await render("welcome.json");
} catch (err) {
  if (err instanceof RenderError) {
    console.error(err.code, err.message);
  }
}

| Code | Meaning | | --- | --- | | TEMPLATE_NOT_FOUND | File missing, unreadable, or outside baseDir (Node) — or source was a path string in the browser/edge build, which has no file system to read one from | | INVALID_TEMPLATE | Not valid JSON, or not a valid template document | | RENDER_ERROR | The engine failed, or engine.wasm couldn't be loaded/instantiated |

Templates are validated before rendering — missing fields, wrong types and unsupported schema versions are reported by name, rather than surfacing as an opaque failure from inside the engine. A missing-file error names the resolved path, since the useful question is usually which directory was searched.

Schema versions are compared on the major only. 1.7 renders fine under a 1.x renderer; 2.0 is rejected with a message telling you to upgrade.

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: "[email protected]",
    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 in a loop is fine.

For Postmark, Amazon SES, or a Cloudflare Workers / Vercel Edge deployment of any of the three, see examples/.

Requirements

Node 20+, or any modern browser, or an edge runtime such as Cloudflare Workers, Vercel Edge, or Deno — see Browsers and edge runtimes. It's the same package and the same import either way; the right build is selected for you.

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" } },
});

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.

dynamicData: { merge_tags, context } is now mergeTags and context at the top level.

License

MIT