Skip to main content
The composeReactEmail function is the core of the editor’s email export system. It takes the editor’s document tree, walks every node and mark, calls each extension’s renderToReactEmail() method, applies theme styles, wraps everything in an email-ready template, and produces both HTML and plain text output.

Import

import { composeReactEmail } from '@react-email/editor/core';

Signature

async function composeReactEmail(params: {
  editor: Editor;
  preview: string | null;
}): Promise<{ html: string; text: string }>;

Parameters

editor
Editor
required
The TipTap editor instance. The function reads the editor’s JSON document and walks through each registered extension to serialize nodes and marks.
preview
string | null
required
Preview text shown in inbox list views before the email is opened. Pass null to omit.

Return value

Returns a Promise that resolves to an object with:
FieldTypeDescription
htmlstringFull HTML email string, ready to send
textstringPlain text version for email clients that don’t support HTML
Both are generated in parallel for performance.

The serialization pipeline

Understanding how composeReactEmail works helps you write better custom extensions and debug rendering issues.

1. Extract document and extensions

The function reads the editor’s JSON document (via editor.getJSON()) and collects all registered extensions into a name-to-extension map for fast lookup.

2. Find the SerializerPlugin

It searches extensions for one that provides a SerializerPlugin — an interface with two methods:
  • getNodeStyles(node, depth, editor) — returns React.CSSProperties for a given node
  • BaseTemplate({ previewText, children, editor }) — wraps the serialized content in an email structure
The EmailTheming extension implements this interface. If no plugin is found, styles default to {} and the built-in DefaultBaseTemplate is used.

3. Traverse the document tree

It recursively walks the ProseMirror document. For each node it:
  1. Resolves styles — calls serializerPlugin.getNodeStyles(node, depth, editor) to get theme styles, then merges any inline styles from the node’s attributes
  2. Renders unknown nodes as null — if the node type isn’t registered or isn’t an EmailNode, it returns null
  3. Renders the node — calls the extension’s renderToReactEmail() component, passing children (from recursing into child nodes), style, node, and extension
  4. Wraps with marks — iterates through the node’s marks (bold, italic, link, etc.) and wraps the rendered output with each mark’s renderToReactEmail()

4. Depth tracking

Depth starts at 0 and only increments inside list nodes (bulletList, orderedList). This enables different styling for nested vs. top-level elements — for example, paragraphs inside list items use the listParagraph theme key instead of paragraph.

5. Style resolution order

Styles are resolved in this priority (highest wins):
  1. Inline styles — styles set directly on a node via the editor (e.g., text alignment)
  2. Theme styles — styles from the active theme via getNodeStyles()
  3. Extension defaults — hardcoded styles in each extension’s renderToReactEmail()
Inside each extension’s renderer, these are typically merged:
renderToReactEmail({ children, style, node }) {
  return (
    <p style={{
      ...style,                            // theme styles
      ...inlineCssToJs(node.attrs?.style), // inline overrides
    }}>
      {children}
    </p>
  );
}

6. Wrap in BaseTemplate

The serialized content is wrapped in a BaseTemplate that provides the email’s outer structure. The default template renders:
<Html>
  <Head>
    <meta content="width=device-width" name="viewport" />
    <meta content="IE=edge" httpEquiv="X-UA-Compatible" />
    <meta name="x-apple-disable-message-reformatting" />
    <meta
      content="telephone=no,address=no,email=no,date=no,url=no"
      name="format-detection"
    />
  </Head>
  {previewText && <Preview>{previewText}</Preview>}
  <Body>
    <Section width="100%" align="center">
      <Section style={{ width: '100%' }}>
        {children}
      </Section>
    </Section>
  </Body>
</Html>
When EmailTheming is active, its BaseTemplate replaces the default — it adds theme-specific body/container styles and can inject global CSS via a <style> tag in the <Head>.

7. Render to HTML and plain text

The React tree is rendered to an HTML string using @react-email/componentsrender() function. Both the formatted HTML and a plain text version (tags stripped, text preserved) are produced in parallel from the final React tree.

Usage

Basic export

import { composeReactEmail } from '@react-email/editor/core';

const { html, text } = await composeReactEmail({ editor, preview: null });

With preview text

The preview parameter sets the inbox preview snippet — the text shown before the email is opened:
const { html, text } = await composeReactEmail({
  editor,
  preview: 'Check out our latest updates!',
});
Pass null to omit preview text entirely.

With theming

When the EmailTheming extension is in your extensions array, theme styles are automatically inlined into every node during export:
import { StarterKit } from '@react-email/editor/extensions';
import { EmailTheming } from '@react-email/editor/plugins';

const extensions = [StarterKit, EmailTheming.configure({ theme: 'basic' })];

// Theme styles are injected automatically — no extra config needed
const { html } = await composeReactEmail({ editor, preview: null });

Full example with export panel

import { composeReactEmail } from '@react-email/editor/core';
import { useCurrentEditor } from '@tiptap/react';
import { useState } from 'react';

function ExportPanel() {
  const { editor } = useCurrentEditor();
  const [html, setHtml] = useState('');
  const [exporting, setExporting] = useState(false);

  const handleExport = async () => {
    if (!editor) return;
    setExporting(true);
    const result = await composeReactEmail({ editor, preview: null });
    setHtml(result.html);
    setExporting(false);
  };

  return (
    <div>
      <button onClick={handleExport} disabled={exporting}>
        {exporting ? 'Exporting...' : 'Export HTML'}
      </button>
      {html && (
        <textarea
          readOnly
          value={html}
          rows={16}
          style={{ width: '100%', fontFamily: 'monospace' }}
        />
      )}
    </div>
  );
}

See also

  • EmailNode — defines how nodes serialize via renderToReactEmail()
  • EmailMark — defines how marks serialize via renderToReactEmail()
  • Email Export — guide with full editor + export examples
  • Theming — how EmailTheming provides styles and templates to the serializer