MJML
Templates

MJML

Build responsive email templates with MJML

The @betternotify/mjml adapter combines MJML with Handlebars in a two-phase rendering pipeline: MJML compiles to responsive HTML tables at creation time (once), then Handlebars resolves {{variable}} placeholders on each render call.

Install

pnpm add @betternotify/mjml mjml handlebars

MJML requires a Node.js runtime. For Cloudflare Workers or edge runtimes, use React Email or Handlebars instead.

Basic usage

import { z } from 'zod';
import { mjmlTemplate } from '@betternotify/mjml';

const welcome = rpc
  .email()
  .input(z.object({ name: z.string(), code: z.string() }))
  .subject(({ input }) => `Welcome, ${input.name}!`)
  .template(mjmlTemplate(`
    <mjml>
      <mj-body>
        <mj-section>
          <mj-column>
            <mj-text font-size="20px">Hello {{name}}</mj-text>
            <mj-text>Your verification code: {{code}}</mj-text>
          </mj-column>
        </mj-section>
      </mj-body>
    </mjml>
  `));

The {{name}} and {{code}} placeholders survive MJML compilation as literal text in the responsive HTML output, then Handlebars resolves them against input at render time.

How the two-phase rendering works

  1. Creation time — MJML source compiles to responsive HTML tables. This is the expensive step (parsing MJML, generating table-based layouts, inlining styles). It happens once when the adapter is created.
  2. Render time — Handlebars resolves {{}} placeholders in the already-compiled HTML using input as the data context. This is fast since MJML compilation already happened.

This design means MJML compilation cost is paid at startup, not on every send.

Text and subject templates

MJML produces HTML only. To include a plain-text alternative and subject, pass Handlebars template strings:

.template(mjmlTemplate(mjmlSource, {
  text: 'Hello {{name}}. Your code: {{code}}',
  subject: 'Welcome, {{name}}!',
}))

Helpers and partials

Full Handlebars syntax works inside MJML markup — conditionals, loops, helpers, and partials:

.template(mjmlTemplate(`
  <mjml>
    <mj-body>
      <mj-section>
        <mj-column>
          <mj-text>Hello {{upper name}}</mj-text>
          {{#each items}}
          <mj-text>• {{this}}</mj-text>
          {{/each}}
        </mj-column>
      </mj-section>
    </mj-body>
  </mjml>
`, {
  helpers: { upper: (str: string) => str.toUpperCase() },
}))

Validation

By default, the adapter throws on MJML validation errors (unregistered elements, structural issues) at creation time — you find problems at startup, not at send time. To opt out:

mjmlTemplate(source, { mjml: { validationLevel: 'skip' } })

API

mjmlTemplate(source, opts?)

Prop

Type

Returns a TemplateAdapter<TInput>.

When to use MJML

MJML is a good fit when you want responsive email layouts without hand-writing the table-based HTML that email clients require, and your team prefers a markup language over React components. The Handlebars interpolation layer makes it easy for non-developers to edit template content without understanding the MJML structure.

On this page