Skip to main content

Building a Custom MJML Component

Write an MJML component that compiles to correct table markup: allowed attributes, the render method, where it may sit in the tree, and registering it in the build.

When a pattern recurs across templates and no built-in component expresses it, a custom component turns repeated markup into one tag. This guide covers writing one that compiles to correct table output, declaring where it may sit in the document tree, and registering it so the build resolves it.

Root Cause: Includes Copy Markup, Components Encapsulate It

The usual first answer to repetition is mj-include, which pulls a fragment into the document at compile time. It works and it has a specific limit: an include is a textual insertion with no parameters, so a fragment that differs by colour or label per use has to become several fragments, and they drift.

A component is a function of its attributes. It declares which attributes it accepts, provides defaults, and renders markup from them — so one definition covers every variation, and adding a variation is a new attribute value rather than a new file.

The second reason to prefer a component is that it participates in MJML's layout system. The compiler knows a component's allowed parent and children, so it can reject an invalid arrangement at compile time and can apply the width and spacing calculations that make the surrounding table markup correct. An include is opaque to all of that — the compiler pastes it in and hopes.

Include versus component An include is a textual paste that multiplies into one file per variation, while a component takes attributes and participates in layout validation. One Definition, or One File Per Variation mj-include alert-info.mjml, alert-warn.mjml… no parameters, so variants multiply opaque to layout validation the files drift apart over time custom component mj-alert level="warning" attributes with types and defaults validated against the tree one definition, every variation
The decisive difference is parameters: an include cannot take them, so every variation becomes another file that can drift.

The Exact Component

A component extends the body-component base class, declares its attributes, states where it may sit, and returns markup from render().

// components/MjAlert.js — a coloured callout used across several templates.
import { BodyComponent } from "mjml-core";

export default class MjAlert extends BodyComponent {
  // The tag name authors write. Prefixing with mj- keeps it visually
  // consistent with the built-ins.
  static componentName = "mj-alert";

  // Declaring this means the compiler rejects an mj-alert placed anywhere
  // other than inside a column — a compile error instead of broken markup.
  static endingTag = true;

  static allowedAttributes = {
    // The types are enforced by the compiler: an invalid enum value or a
    // malformed colour fails the build rather than rendering wrong.
    level: "enum(info,success,warning,error)",
    "background-color": "color",
    "border-color": "color",
    color: "color",
    padding: "unit(px,%){1,4}",
    "font-size": "unit(px)",
  };

  static defaultAttributes = {
    level: "info",
    padding: "16px",
    "font-size": "16px",
  };

  // Palette per level. Defined here rather than in each template so a brand
  // change is one edit.
  static PALETTE = {
    info:    { bg: "#fdf2f5", border: "#a53860", text: "#450920" },
    success: { bg: "#f0f7f2", border: "#2f7d4f", text: "#1d4d31" },
    warning: { bg: "#fdf6e7", border: "#b45309", text: "#6b3300" },
    error:   { bg: "#fdf0f0", border: "#b3261e", text: "#6b1512" },
  };

  render() {
    const level = this.getAttribute("level");
    const p = MjAlert.PALETTE[level];

    // Explicit attributes win over the palette, so a template can override
    // one colour without abandoning the component.
    const bg = this.getAttribute("background-color") || p.bg;
    const border = this.getAttribute("border-color") || p.border;
    const color = this.getAttribute("color") || p.text;

    // A table rather than a div: the Word engine lays out tables reliably and
    // ignores most block-level styling on anything else.
    return `
      <table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"
             style="${this.styles("wrapper")}">
        <tbody>
          <tr>
            <td style="
              background-color:${bg};
              border-left:4px solid ${border};
              color:${color};
              padding:${this.getAttribute("padding")};
              font-family:Arial,sans-serif;
              font-size:${this.getAttribute("font-size")};
              line-height:24px;
              mso-line-height-rule:exactly;
            ">
              ${this.getContent()}
            </td>
          </tr>
        </tbody>
      </table>
    `;
  }
}

Three details are worth calling out. endingTag = true tells the compiler this component's children are raw content rather than further MJML components, which is what makes this.getContent() return the author's markup. The attribute type declarations are enforced — enum(...) rejects an unknown level at compile time, which turns a typo into a build failure instead of an unstyled callout. And rendering a table rather than a div is the same constraint every email component operates under.

Declaring Where It May Sit

MJML's document model is strict, and a component that does not declare its position produces confusing errors or silently wrong output.

The rule the compiler enforces is that content components live inside mj-column, columns live inside mj-section, and sections live inside mj-body. A custom content component therefore needs no explicit parent declaration — extending BodyComponent places it in the content tier automatically — but it does need to accept that placement rather than trying to render its own section wrapper.

The mistake this prevents is a component that renders a full-width band including its own <table> wrapper and is then placed inside a column, producing a table inside a cell inside a table with three sets of competing widths. Where a component genuinely needs to be full-bleed, it should extend the section tier instead, and the author places it as a sibling of mj-section rather than inside a column.

Placement in the MJML tree A content-tier component is placed inside a column, while a full-bleed component must extend the section tier and sit beside other sections. Extend the Tier You Will Be Placed In content tier — inside a column mj-section > mj-column mj-alert renders a table, not a section section tier — full bleed mj-section mj-banner — a sibling, not a child extends the section base class instead
A content component that renders its own section wrapper produces nested tables with competing widths — the compiler cannot warn about it.

Registering It in the Build

A component must be registered before the compiler encounters the tag, which in practice means registering at the top of the build script rather than relying on configuration discovery.

// build.mjs
import mjml2html from "mjml";
import { registerComponent } from "mjml-core";
import MjAlert from "./components/MjAlert.js";

// Register BEFORE any compile call. A tag the compiler does not know is
// dropped silently in some versions and raises in others — neither is a
// failure mode you want discovered at send time.
registerComponent(MjAlert);

export function compile(source) {
  const { html, errors } = mjml2html(source, {
    validationLevel: "strict",   // unknown attributes and misplacement fail
    keepComments: true,          // MSO conditionals are comments
  });
  if (errors.length) {
    throw new Error(errors.map((e) => `${e.tagName}: ${e.message}`).join("\n"));
  }
  return html;
}

validationLevel: "strict" is what makes the attribute declarations earn their keep. At the default level an unknown attribute is ignored, so levl="warning" renders an info alert and nobody notices; at strict level it fails the build with the tag and attribute named.

keepComments: true matters for the same reason it does in the inline step: MSO conditionals are comments, and a compiler configured to strip comments removes the Outlook path from every template it touches.

Widths, Padding and the Calculations You Inherit

The compiler computes widths for the built-in components, and a custom component either participates in that or fights it. Understanding which is which avoids a class of bug that presents as "the component is 20 pixels too wide in one template".

MJML resolves a column's usable width by taking the section width, subtracting the section's padding, dividing by the number of columns, then subtracting the column's own padding. A content component inside that column receives the result and should render at width="100%" of it — not at a fixed pixel value, which would be correct in the template it was built for and wrong in every other column configuration.

The base class exposes the containing width, so a component that genuinely needs a pixel value can compute one rather than hard-coding it. That matters for anything with an intrinsic dimension: an image, a fixed-width button, a VML shape whose rect must be sized in pixels because VML has no percentage width.

render() {
  // The width the parent column actually gives us, after all padding.
  // Hard-coding 600 here works in a one-column section and is wrong in two.
  const available = this.context.containerWidth;   // e.g. "552px"
  const px = parseInt(available, 10);

  return `
    <table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
      <tbody><tr><td>
        <!--[if mso]>
        <v:rect style="width:${px}px;height:120px;" fill="true" stroke="false">
        <![endif]-->
        ${this.getContent()}
        <!--[if mso]></v:rect><![endif]-->
      </td></tr></tbody>
    </table>
  `;
}

Padding is the other inherited concern. A component that applies its own padding in addition to the column's produces double spacing that looks like a design error, and the usual instinct — reducing the component's padding until it looks right in one template — breaks it everywhere else. The convention that avoids this is that columns own outer spacing and components own only their internal spacing, which is the same division the built-in components follow.

How the available width is computed Section width minus section padding, divided by column count, minus column padding, gives the width a content component receives. Never Hard-Code the Width mj-section — 600px, minus 24px padding each side 552px available, divided by the column count mj-column — 276px, minus its own padding mj-column — 276px, minus its own padding your component: containerWidth your component: containerWidth
Reading the container width means the component is correct in a one-column section and a three-column one without any change.

Validation and Deployment Checklist

Frequently Asked Questions

When is a custom component worth it over mj-include?

When the pattern varies. An invariant fragment — a legal footer, a fixed header — is well served by an include, and a component adds indirection for no benefit. The moment you find yourself maintaining footer-uk.mjml and footer-us.mjml that differ by two strings, the pattern has become parametric and wants a component.

Can a component contain other MJML components?

Yes, by setting endingTag = false and rendering this.renderChildren() instead of this.getContent(). That makes it a container in the same sense as mj-column, and it also means the children are compiled by MJML rather than passed through as raw markup — which is what you want for a card wrapping arbitrary content.

How do we test a custom component?

Compile a fixture document using it and snapshot the HTML, exactly as for any other compiled output. Add explicit assertions for the client workarounds the component carries, so a refactor that removes the MSO line-height rule fails with a message rather than a silent snapshot diff.

Does a custom component work with the MJML CLI and editors?

The CLI resolves components registered through its configuration file, so a component published as a package can be listed there and used from the command line. Editor tooling generally will not know the tag, so authors lose autocomplete for it — which is an argument for keeping the attribute surface small and documenting it where the templates live.


← Back to MJML Component Architecture