the build workflow for compatible HTML email
Tailwind CSS utility classes are ignored by most email clients because external stylesheets never arrive in the inbox. A build pipeline that compiles Tailwind and automatically writes the generated rules as inline styles into every HTML element solves this without extra manual work.
Table of Contents
- 1. Why email clients don't load external CSS
- 2. Utility classes in the browser versus inline styles in the inbox
- 3. Tools overview: juice, PostCSS, and custom scripts
- 4. The build pipeline: compile Tailwind, then inline
- 5. Practical example: order confirmation from classes to inline styles
- 6. Limits of inlining: pseudo classes and media queries
- 7. Systematically testing generated inline styles
- 8. Maintainability: why Tailwind stays in the source
- 9. Inlining tools compared directly
- 10. Summary
- 11. FAQ
1. Why email clients don't load external CSS
A browser loads a stylesheet through <link rel="stylesheet"> and applies the rules to the document, regardless of whether the classes come from Tailwind, a custom framework, or hand-written CSS. Email clients like Outlook, Gmail, or the iOS Mail app do not do this reliably. Some strip the entire <head> section, others ignore <style> blocks in the body, and still others only support a subset of CSS selectors. The result: a template built with Tailwind that looks perfect in the browser arrives in the inbox as unformatted plain text.
The only CSS mechanism that works reliably across virtually all email clients is the style attribute directly on the HTML element. Inline styles are not filtered out because they are part of the element itself and require no separate resource or selector parsing pass. Anyone building HTML email with Tailwind CSS therefore needs a way to turn the utility classes that are convenient during development into exactly these inline styles at the end, without translating classes into style attributes by hand.
2. Utility classes in the browser versus inline styles in the inbox
In the normal frontend workflow you write class="px-6 py-4 bg-sky-600 text-white rounded-lg" and the Tailwind compiler generates a stylesheet once with the matching CSS rules that are applied to any number of elements through the cascade. This mechanism is Tailwind's strength in the browser, but it becomes a weakness in email because the stylesheet itself never reaches the recipient. The class exists in the HTML, but the associated rule is completely missing as soon as the client doesn't pick up CSS from the head section.
The solution is a two-stage process: first, Tailwind compiles a full stylesheet from the classes used, exactly as usual. Then an inlining tool takes every single rule from that stylesheet and writes it directly into the style attribute of the matching element. In the end you have HTML where class="px-6 py-4 bg-sky-600" has become style="padding-left:1.5rem;padding-right:1.5rem;padding-top:1rem;padding-bottom:1rem;background-color:#0284c7;", while the original classes can remain in the source for maintainability.
/* Tailwind's compiled stylesheet before inlining — one rule per utility class */
.px-6 { padding-left: 1.5rem; padding-right: 1.5rem; }
.py-4 { padding-top: 1rem; padding-bottom: 1rem; }
.bg-sky-600 { background-color: #0284c7; }
.text-white { color: #ffffff; }
.rounded-lg { border-radius: 0.5rem; }
3. Tools overview: juice, PostCSS, and custom scripts
For automatically generating inline styles, the package juice has become the standard in the Node ecosystem. It takes an HTML document and a CSS stylesheet, computes the specificity of the matching rules for each element, and writes the result as a style attribute into the tree. For Tailwind projects that means: Tailwind generates the CSS as usual, juice then takes over the inlining step, without writing your own CSS parsing logic.
Alternatively, PostCSS plugins exist that hook the inlining step directly into the existing PostCSS pipeline where Tailwind already runs. This reduces the number of build steps because no separate Node call is needed after the CSS build. For smaller projects, a custom script of just a few lines that calls juice.inlineContent() is also enough. In any case, it's important that the inlining tool leaves media queries and pseudo classes like :hover untouched in a remaining <style> block, because these cannot be meaningfully represented inline.
# Install the tools needed for the Tailwind-to-inline pipeline
npm install --save-dev tailwindcss juice
# tailwind.config.js scans only the email templates directory
# so unrelated utility classes never end up in the compiled CSS
4. The build pipeline: compile Tailwind, then inline
The complete pipeline consists of three steps that fit into a single npm script. First, the Tailwind CLI compiles a stylesheet from a source file that scans only the classes used in the email templates. Second, a small Node script reads both the HTML template and the generated CSS. Third, this script calls juice and writes the result as a finished, inline-styled HTML file into an output folder that is used directly by the mail delivery system.
The trick behind this pipeline: during development you keep working comfortably with Tailwind classes in the template, including editor autocomplete and instant visual feedback in the browser. Only the build step produces the inline styles that email clients require. This cleanly separates the development experience from the delivery format, much like Tailwind already does between source code and compiled stylesheet in the normal web context.
{
"scripts": {
"build:email": "node build-email.js",
"watch:email": "tailwindcss -i ./src/email.css -o ./dist/email.css --watch"
},
"devDependencies": {
"tailwindcss": "^4.0.0",
"juice": "^10.0.0"
}
}
// build-email.js — compile Tailwind, then inline every rule into style attributes
const fs = require("fs");
const juice = require("juice");
const { execSync } = require("child_process");
// Step 1: compile Tailwind CSS scoped to the email templates
execSync("npx tailwindcss -i ./src/email.css -o ./dist/email.css --minify");
const html = fs.readFileSync("./src/order-confirmation.html", "utf8");
const css = fs.readFileSync("./dist/email.css", "utf8");
// Step 2 + 3: inline every compiled rule into a style attribute
const inlined = juice.inlineContent(html, css, {
removeStyleTags: true,
preserveMediaQueries: true,
preservePseudos: true,
});
fs.writeFileSync("./dist/order-confirmation.html", inlined);
console.log("Inline styles generated: dist/order-confirmation.html");
5. Practical example: order confirmation from classes to inline styles
A concrete example makes the effect tangible. An order confirmation contains a header with a logo, a product table, and a call-to-action button. In the source template you write the button just normally with Tailwind classes: class="inline-block bg-sky-600 text-white font-bold py-3 px-6 rounded-lg". After the build step, exactly this spot has a complete style attribute with all computed declarations, while the class itself usually stays in the output HTML and only serves as a fallback in case a client does respect CSS classes after all.
For layout elements like the outer table, which in email is usually built as an HTML <table> instead of flexbox or grid for compatibility reasons, the pipeline generates inline styles for cell padding, background colors, and borders. This lets you carry over the utility pattern known from Tailwind on the web almost unchanged onto the table-based email structure, except that inline styles arrive in the inbox instead of an external stylesheet.
<!-- Source template: Tailwind utility classes for readability during development -->
<table class="w-full bg-white rounded-lg overflow-hidden" cellpadding="0" cellspacing="0">
<tr>
<td class="px-6 py-4 bg-slate-50 border-b border-slate-200">
<a class="inline-block bg-sky-600 text-white font-bold py-3 px-6 rounded-lg"
href="https://mironsoft.de/order/12345">View order</a>
</td>
</tr>
</table>
<!-- Output after the inline build step: style attributes only, class kept as fallback -->
<table style="width:100%;background-color:#ffffff;border-radius:0.5rem;" cellpadding="0" cellspacing="0">
<tr>
<td style="padding:1rem 1.5rem;background-color:#f8fafc;border-bottom:1px solid #e2e8f0;">
<a style="display:inline-block;background-color:#0284c7;color:#ffffff;font-weight:700;padding:0.75rem 1.5rem;border-radius:0.5rem;"
href="https://mironsoft.de/order/12345">View order</a>
</td>
</tr>
</table>
6. Limits of inlining: pseudo classes and media queries
Not every Tailwind rule can be meaningfully represented as an inline style. Pseudo classes like hover:bg-sky-700 refer to an interaction state that doesn't exist within the style attribute itself. Such rules have to remain in a leftover <style> block in the head section, hoping that the given client respects that block. Good inlining tools like juice detect this automatically through the preservePseudos option and cleanly separate rules that can be inlined from those that cannot.
The same applies to responsive utility classes like sm:px-8, which are based on media queries. An inline style cannot express a condition such as a screen width, so these rules necessarily stay in a <style> block. Since media queries work in modern clients like Apple Mail and partly Gmail but are unreliable in Outlook, an email template's responsive behavior should never rely exclusively on them. Fluid table layouts with percentage widths are more robust than pure media query dependency here.
/* These rules stay in the remaining style block after inlining */
@media (max-width: 480px) {
.sm\:px-8 { padding-left: 1rem !important; padding-right: 1rem !important; }
}
.hover\:bg-sky-700:hover { background-color: #0369a1; }
7. Systematically testing generated inline styles
After the build step, it's worth a visual check of the generated HTML in several real clients, not just the browser. Outlook on Windows uses the Word rendering engine for display, which supports a significantly narrower CSS subset than WebKit-based clients. An inline style that looks correct in the browser can still break in Outlook if it relies on a CSS property Word doesn't know, such as border-radius or box-shadow. These properties are simply ignored by Outlook, but the rest of the layout stays intact.
For automated checks in the build process, a simple lint step is useful that scans the generated HTML for known problem cases, for example leftover class references to classes that no longer exist in the CSS, or deeply nested <div> structures that are supported worse in email than flat table structures. Such a check runs in seconds and catches many errors before the template even lands in a real inbox.
8. Maintainability: why Tailwind stays in the source
One could argue it would be simpler to develop directly with inline styles and skip the detour through Tailwind entirely. In practice the opposite is true: maintaining inline styles by hand means searching and replacing the same color or the same spacing in multiple places in the HTML every time the design changes. With Tailwind in the source, the single source of truth for colors, spacing, and typography stays in tailwind.config.js, while the build step handles the translation into inline styles.
This pays off especially when several email templates share the same color scheme, for example order confirmation, shipping notification, and invoice. If the brand color changes, one adjustment in the Tailwind theme is enough, and all templates automatically generate updated inline styles on the next build. Without this pipeline, every single HTML file would have to be searched manually, which becomes an error source as the number of templates grows, one that an automated pipeline rules out from the start.
9. Inlining tools compared directly
When choosing the right tool for generating inline styles from Tailwind classes, it's worth looking at the concrete differences between the common options.
| Tool | Integration | Pseudo classes | Best fit |
|---|---|---|---|
| juice (Node) | Standalone build script after Tailwind | Kept in a style block | Default choice for Tailwind projects |
| PostCSS plugin | Directly in existing PostCSS pipeline | Kept in a style block | Fewer build steps, more configuration |
| MJML with Tailwind classes | Compiles components to table HTML | Limited via mj-style | When layout components matter more |
| Manual inlining | No build step, fully manual | Error prone on changes | Only for one-off, very small emails |
juice remains the most pragmatic choice for most Tailwind-based email projects because it fits into an existing Node toolchain without major rework and correctly leaves media queries and pseudo classes in a remaining style block. PostCSS plugins are interesting when a more complex PostCSS configuration already exists. MJML fits when not just styling but also the table structure itself should be automated.
# Run the full pipeline as part of the deployment build
npm run build:email
ls -la dist/*.html # inspect the inline-styled output before sending
Mironsoft
Tailwind CSS, email templates, and build pipelines for Magento and Symfony
HTML emails that look right in every inbox?
We build pipelines that automatically turn Tailwind classes into compatible inline styles, test the result in real clients, and integrate the templates into your sending system.
Build pipeline
Compile Tailwind and generate inline styles automatically with juice
Client testing
Checking the generated templates in Outlook, Gmail, and Apple Mail
Integration
Connecting to mail delivery systems in Magento and Symfony projects
10. Summary
Generating inline styles from Tailwind instead of writing them by hand solves the fundamental compatibility problem of HTML email: external stylesheets don't reliably arrive in the inbox, but inline styles do. A build pipeline made of the Tailwind CLI and an inlining tool like juice handles the translation automatically, while development itself still happens with convenient utility classes. Media queries and pseudo classes stay in a separate style block, because they cannot be meaningfully represented inline.
The biggest win of this approach lies in maintainability across multiple templates. Instead of manually maintaining colors and spacing in every single HTML file, the Tailwind theme stays the single source of truth, and every template automatically generates current inline styles on build. Anyone who regularly adjusts HTML emails saves significant manual effort with this pipeline while also reducing the risk of inconsistent inline styles between different templates.
Teams that additionally generate PDF documents from the same codebase benefit from looking at the separate pipeline with Puppeteer or Dompdf, because many basic principles like table-based layout and the consistent separation of source code and output format carry over directly. This investment in a clean build pipeline pays off across formats, regardless of whether the end result is an email or a PDF document.
# Quick sanity check before every send: no leftover unresolved classes
grep -c "class=" dist/order-confirmation.html
grep -c "style=" dist/order-confirmation.html
# Exit non-zero in CI if the inline step produced no output at all
test -s dist/order-confirmation.html
Generating Inline Styles from Tailwind — Key Takeaways
Why inline at all
Email clients ignore external stylesheets and often style blocks too. Only inline styles in the style attribute are reliably compatible.
The pipeline
Tailwind compiles the CSS, juice writes every rule as an inline style into the matching HTML element.
Limits
Pseudo classes and media queries stay in a separate style block, because inline styles cannot express conditions.
Maintainability
The Tailwind theme stays the single source of truth, all templates update their inline styles automatically on the next build.