Designing Transactional Emails with Tailwind CSS and MJML
AI generated
</>
tw
Tailwind CSS · MJML · Transactional Emails · PHP
Transactional Emails with Tailwind and MJML
components instead of copy-pasted tables

Order confirmations, shipping notifications, and password reset emails almost always share the same basic structure. MJML provides the reliable, table-based layout layer for this, while Tailwind utility classes deliver the visual design, so transactional emails stay maintainable instead of consisting of scattered copies.

18 min read MJML · Tailwind CSS · Symfony Mailer Order confirmation · shipping · password reset

1. Why transactional emails need their own template strategy

Transactional emails like order confirmations, shipping notifications, invoices, and password resets differ from marketing newsletters in that they are triggered in real time by a backend event and usually contain business-critical information. A poorly rendered order confirmation costs trust and generates support effort, while a delayed newsletter usually has no consequences. This higher reliability requirement makes a structured template strategy for transactional emails more important than for other email types.

At the same time, almost all transactional emails of a shop or application share the same building blocks: a header with a logo, a main message, a table with line items or details, a call-to-action button, and a footer with legal information. If these building blocks are copied separately as table HTML for every transactional email, sprawl quickly sets in, where a design change has to be manually replicated in multiple places. This is exactly where MJML and Tailwind come in together.

2. MJML as a layout layer for reliable table structures

MJML is a markup language developed by Mailjet that provides its own declarative components like <mj-section>, <mj-column>, and <mj-button>, and automatically translates them at compile time into robust, table-based HTML with all the necessary Outlook fallbacks. The decisive advantage for transactional emails: instead of hand-writing every nested table structure and every MSO conditional comment, you describe the layout at a higher abstraction level while MJML takes over the error-prone detail work.

For teams already working with Tailwind CSS, MJML initially feels like a break from the familiar utility class workflow, because MJML offers its own attributes like background-color or padding directly on its components. The pragmatic middle ground is to use MJML exclusively for the layout structure, while visual fine-tuning like text colors, font sizes, and spacing within the components is controlled via embedded Tailwind CSS, which MJML passes through untouched.


# Install MJML alongside the existing Tailwind toolchain
npm install --save-dev mjml juice tailwindcss

3. Combining Tailwind classes inside MJML components

MJML supports an <mj-raw> block as well as <mj-html-attributes>, with which normal HTML with Tailwind classes can be embedded into an MJML template without leaving MJML's own component logic. In practice this means: the outer structure, sections and columns, is described with MJML tags, while the content inside a column, for example a product table or a text block, is styled quite normally with Tailwind classes in the embedded HTML.

For the Tailwind classes to actually become inline styles, the result of MJML is compiled in a second step additionally through the inlining pipeline with juice known from another article. The workflow then runs in three stages: MJML compiles the robust layout structure, Tailwind provides the stylesheet for the classes used in the embedded HTML, and juice finally writes all rules as inline styles into the finished document.


<!-- order-confirmation.mjml — MJML structure with embedded Tailwind classes -->
<mjml>
  <mj-body background-color="#f8fafc">
    <mj-section padding="0">
      <mj-column>
        <mj-raw>
          <!-- Content inside this raw block uses ordinary Tailwind utility classes -->
          <div class="px-6 py-8 bg-sky-600 text-white">
            <p class="text-xl font-bold m-0">Order #12345 confirmed</p>
          </div>
        </mj-raw>
      </mj-column>
    </mj-section>
  </mj-body>
</mjml>

4. The build process: compile MJML, embed Tailwind

The complete build process for a transactional email consists of a fixed sequence of steps that fit well into an npm script or a PHP build pipeline. First, the MJML compiler compiles the .mjml source file into valid, table-based HTML with all Outlook fallbacks. Then Tailwind compiles the CSS for the utility classes used in the MJML document. Finally, juice handles the inlining, so a finished, ready-to-send HTML file emerges at the end.

For projects where the backend logic runs in PHP instead of Node.js, for example in Symfony or Magento, this build step can be run once as a separate compilation process, whose result is then stored as a static Twig or PHTML template in the PHP project. The dynamic placeholders for order number, customer name, or item list remain as normal template variables and are only filled in at runtime by the PHP mailer, not already during the MJML build.


{
  "scripts": {
    "build:transactional": "npm run build:mjml && npm run build:tailwind && npm run build:inline",
    "build:mjml": "mjml src/*.mjml --output dist/",
    "build:tailwind": "tailwindcss -i src/email.css -o dist/email.css --minify",
    "build:inline": "node inline.js"
  }
}

# Full build chain: MJML -> Tailwind CSS -> inline styles
npx mjml src/order-confirmation.mjml -o dist/order-confirmation.raw.html
npx tailwindcss -i src/email.css -o dist/email.css --minify
node inline.js dist/order-confirmation.raw.html dist/email.css dist/order-confirmation.html
# Repeat for every transactional email template sharing the same build script
for tpl in order-confirmation shipping-notification invoice; do
  npx mjml "src/$tpl.mjml" -o "dist/$tpl.raw.html"
  node inline.js "dist/$tpl.raw.html" dist/email.css "dist/$tpl.html"
done

5. Reusable components for all transactional emails

MJML supports native includes via <mj-include>, with which recurring building blocks like header, footer, and call-to-action button can be maintained in their own files, shared by all transactional emails. An order confirmation, a shipping notification, and an invoice thus share exactly the same header and footer file, while only the individual main content varies per email type. If the logo or the legal footer text changes, a single adjustment in the shared include file is enough.

For the call-to-action button, a dedicated MJML component with a parameterizable text and link is recommended, consistently using the same Tailwind class combination for background color, padding, and font weight. This way the button in the order confirmation looks visually identical to the one in the shipping notification, without the classes having to be rewritten at every location.


<!-- shared-header.mjml — included by every transactional email template -->
<mj-section background-color="#0f172a" padding="24px 0">
  <mj-column>
    <mj-image width="140px" src="https://mironsoft.de/logo.png" />
  </mj-column>
</mj-section>

<!-- order-confirmation.mjml — reuses the header via mj-include -->
<mjml>
  <mj-body>
    <mj-include path="./shared-header.mjml" />
    <mj-section><mj-column><mj-text>Order-specific content here</mj-text></mj-column></mj-section>
  </mj-body>
</mjml>

6. Practical example: order confirmation as an MJML template

A complete order confirmation consists of the shared header, an individual greeting text with the order number, a product table with line items, prices, and total, and a call-to-action button for order tracking. The product table is embedded as a native HTML <table> element inside an <mj-raw> block, with Tailwind classes for cell padding and alternating row colors, while MJML handles the outer section and column layout.

For the dynamic line items, the underlying template engine, for example Twig in Symfony, uses a loop over the order items, with each row receiving the same Tailwind class combination. The result is an order confirmation that looks correct in Outlook thanks to MJML's table fallbacks, is styled reliably in Gmail thanks to the subsequent inlining pipeline, and whose source code stays readable for developers thanks to Tailwind classes.


<!-- Twig loop inside the mj-raw block, filled at runtime by Symfony Mailer -->
<table class="w-full">
  {% for item in order.items %}
  <tr class="border-b border-slate-200">
    <td class="p-3">{{ item.name }}</td>
    <td class="p-3 text-right">{{ item.quantity }}</td>
    <td class="p-3 text-right">{{ item.total }}</td>
  </tr>
  {% endfor %}
</table>

7. Integration into Symfony Mailer and Magento transactions

In Symfony projects, the compiled, inline-styled HTML file can be registered directly as a Twig template with Symfony Mailer, with MJML and Tailwind compilation running as a separate build step before deployment, not at runtime. The mailer itself doesn't need to know about MJML or Tailwind at all, but merely delivers finished HTML with inserted Twig variables to the respective email provider.

In Magento contexts, the same approach can be used for custom transactional email templates sent via Magento\Framework\Mail\Template\TransportBuilder. The compiled HTML file is stored as an email template in the admin area, with Magento's own variable syntax using double curly braces for dynamic values continuing to work, as long as the MJML and Tailwind build step passes these placeholders through unchanged, instead of interpreting them.

8. Using dynamic data and placeholders safely

An important aspect of combining MJML, Tailwind, and a server-side template engine is the clear separation of compilation phases. MJML and Tailwind run once during the build and produce a static HTML file with placeholders for dynamic values. These placeholders, whether Twig syntax, Magento directives, or simple placeholder strings, must not be altered by the MJML compiler or the inlining tool, otherwise later value substitution fails.

In practice this means choosing placeholders so they don't collide with MJML or HTML syntax, and running the build step once with sample data as a test, to verify all placeholders are still present unchanged after the build. An automated test that specifically searches the compiled file for the expected placeholder strings after the build catches regressions before a broken template goes into production.


# Automated regression test: verify placeholders survive the MJML + Tailwind build
grep -q "{{ order.number }}" dist/order-confirmation.html \
  && echo "OK: placeholder present" \
  || echo "FAIL: placeholder missing, template is broken"
# Run this check as part of CI right after the build step completes

9. MJML compared to plain table HTML

Whether the additional MJML compilation step pays off depends heavily on the number and complexity of transactional email templates. The following table compares both approaches directly.

Criterion Plain table HTML MJML plus Tailwind
Maintainability across templates Copies per email type Shared includes for header, footer, buttons
Outlook fallbacks Write manually Automatic via the MJML compiler
Learning curve Only HTML and CSS needed Additional MJML syntax to learn
Best fit A single, rarely changed email Several transactional email types with shared building blocks

For a single, rarely changed email template, plain table HTML with Tailwind classes can be sufficient. As soon as a project maintains several transactional email types with a shared header and footer, the investment in MJML pays off quickly through lower maintenance effort and automatic Outlook fallbacks.

A good rule of thumb for this decision is the number of planned transactional email types at project start: from three or more types sharing a header and footer, the benefit of MJML almost always outweighs the additional learning curve.

Mironsoft

Tailwind CSS, MJML, and transactional emails for Symfony and Magento projects

Maintainable templates for all transactional emails?

We build MJML component libraries with Tailwind design, shared building blocks, and a build pipeline integrated directly into Symfony Mailer or Magento transactional emails.

Component library

Shared MJML building blocks for header, footer, and buttons

Build pipeline

MJML, Tailwind, and inline styling in one reproducible process

PHP integration

Connecting to Symfony Mailer and Magento TransportBuilder

10. Summary

The combination of MJML and Tailwind CSS solves the structural problem of transactional emails: MJML provides the reliable, table-based layout layer including automatic Outlook fallbacks, while Tailwind utility classes deliver the visual design within the components. Via <mj-include>, header, footer, and call-to-action button can be maintained as shared building blocks for all transactional email types, instead of being copied anew for every template.

The build process of MJML compilation, Tailwind CSS generation, and final inlining with juice can be cleanly separated from the actual backend logic, whether that runs in Symfony Mailer or in Magento's TransportBuilder. Anyone maintaining several transactional email types with a shared design saves considerable maintenance effort with this approach compared to isolated, manually maintained table HTML files.


<!-- shared-cta-button.mjml — one component reused by every transactional email -->
<mj-section>
  <mj-column>
    <mj-raw>
      <a class="inline-block bg-sky-600 text-white font-bold py-3 px-6 rounded-lg"
         href="{{ cta_url }}">{{ cta_label }}</a>
    </mj-raw>
  </mj-column>
</mj-section>

<!-- Included from order-confirmation.mjml, shipping-notification.mjml, and invoice.mjml -->
<!-- with cta_url and cta_label filled in individually by each calling template -->

This reusability not only reduces maintenance effort but also prevents visual inconsistencies between the individual transactional email types, which otherwise easily arise when every developer rewrites the button code by hand for a new template.

Transactional Emails with Tailwind and MJML — Key Takeaways

Division of roles

MJML for reliable table structure and Outlook fallbacks, Tailwind for visual design within the components.

Reuse

mj-include for shared header, footer, and buttons across all transactional email types.

Build process

Compile MJML, generate Tailwind CSS, inline with juice, ship as a static template.

PHP integration

Compiled HTML as a Twig template in Symfony Mailer or as a template in Magento's TransportBuilder.

11. FAQ: Transactional Emails with Tailwind CSS and MJML

1Why MJML instead of plain table HTML?
MJML automatically handles the error-prone translation into nested tables and Outlook fallbacks.
2How do I combine Tailwind with MJML?
Via mj-raw blocks with embedded HTML and Tailwind classes inside the MJML structure.
3Are classes inlined after MJML compilation?
Yes, with an additional step via juice that runs after MJML compilation.
4How do I share header and footer?
Via mj-include for shared building blocks across all transactional email templates.
5Do Twig variables survive the build?
Yes, as long as they don't collide with MJML or HTML syntax.
6How do I integrate this into Symfony Mailer?
Register the compiled HTML as a Twig template, build runs beforehand, not at runtime.
7Does this work with Magento's TransportBuilder?
Yes, the compiled HTML is stored as a template, Magento variables remain untouched.
8Is MJML worth it for a single template?
For a single template, plain table HTML is often sufficient.
9Do I need to learn MJML syntax too?
Yes, but the effort is manageable and pays off through automatic fallbacks.
10How do I check placeholders after the build?
With an automated test that searches specifically for the expected placeholder strings.