React Email and Outlook: MSO conditional comments, without the hack

If you've ever built an HTML email that needs to render in both Apple Mail and Outlook, you know the trick: MSO conditional comments.

<!--[if mso]>
  <table><tr><td width="600">Ghost table for Outlook</td></tr></table>
<![endif]-->
<!--[if !mso]><!-->
  <div style="max-width:600px">Modern layout</div>
<!--<![endif]-->

Outlook renders the table. Every other client renders the div. This has been the pattern for 20 years and it still works.

It also doesn't work in React.

Why React breaks MSO

React doesn't emit HTML comments. There's no JSX syntax for them. You can't write <!-- foo --> in a component — it's not valid JSX. And the workarounds are all bad:

  • {/* foo */} is a JavaScript comment. React strips it before render.
  • dangerouslySetInnerHTML breaks JSX nesting. You can't wrap children in a comment that way.
  • Escape hatches like React.createElement('comment') don't produce comments — they produce <comment> tags.

This has been an open question in the react-email ecosystem since day one. Most codebases I've seen solve it with string replacement after render — find a sentinel, replace it with the comment. Works, but every project reinvents the pattern.

react-email-mso

react-email-mso is ~20 lines of code that solves this cleanly.

import { render } from '@react-email/render';
import { Outlook, processConditionals } from 'react-email-mso';
 
const Email = () => (
  <Html>
    <Body>
      <Outlook fallback={<table><tr><td width="600">Ghost table</td></tr></table>}>
        <div style={{ maxWidth: 600 }}>Modern layout</div>
      </Outlook>
    </Body>
  </Html>
);
 
const html = processConditionals(await render(<Email />));

That's the whole API. One component, one post-processor.

How it works

<Outlook> renders as a custom HTML element that React passes through untouched. After you render the email to a string, processConditionals rewrites those custom elements into real MSO conditional comments.

React doesn't need to know anything about comments. The custom element is just syntax — a placeholder that survives the render pipeline. The string rewrite runs once, at the end, after all your props and styles have been resolved.

Zero dependencies. Works with react-email, jsx-email, or plain react-dom/server.

Paired and standalone modes

Most of the time you want paired mode: modern content for default clients, a fallback for Outlook.

<Outlook fallback={<table><tr><td>Outlook gets this</td></tr></table>}>
  <div>Modern clients see this</div>
</Outlook>

Standalone mode renders content for Outlook only, or for everything except Outlook:

<Outlook>
  <table><tr><td>Only Outlook sees this</td></tr></table>
</Outlook>
 
<Outlook not>
  <div>Everything except Outlook sees this</div>
</Outlook>

The not prop matters more than it looks. It uses Outlook's downlevel-revealed pattern (<!--[if !mso]><!-->... <!--<![endif]-->) so that non-Outlook clients actually see the content. If you write expr="!mso" by hand, you get a regular conditional comment that's invisible to every client — a footgun the component hides from you.

Version targeting

Target specific Outlook versions with expr:

<Outlook expr="gte mso 9">
  <style>{'body { font-family: Calibri; }'}</style>
</Outlook>

Operators: gt, lt, gte, lte, !. MSO version numbers map to Outlook releases — 9 is Outlook 2000, 16 is Outlook 2016/2019/365.

Nesting

You can nest <Outlook> blocks to version-gate content inside a broader MSO block:

<Outlook>
  <p>All Outlook versions see this.</p>
  <Outlook expr="gte mso 16">
    <p>Only Outlook 2016+ sees this.</p>
  </Outlook>
</Outlook>

The output uses Outlook's short conditional form for the inner block:

<!--[if mso]>
  <p>All Outlook versions see this.</p>
  <![if gte mso 16]>
    <p>Only Outlook 2016+ sees this.</p>
  <![endif]>
<![endif]-->

This is necessary because HTML comments can't nest — a second <!--[if ...]> inside the outer comment would be terminated by its own -->. The short form is Outlook's alternate hidden-conditional syntax, valid inside an already-hidden scope.

Why this didn't exist before

There are a few react-email utility packages floating around, but none of them treat MSO comments as a first-class primitive. The usual answer is "render the comment as a string and inject it with dangerouslySetInnerHTML" — which means your Outlook content can't contain React components.

The custom-element-plus-post-processor pattern is almost too simple. Once you see it, it's obvious. But I spent a few hours searching before I gave up and wrote my own.

Install

npm install react-email-mso

Requires React 19+.

MIT licensed. Two stars so far — one of them is mine.

GitHub: stewartjarod/react-email-mso