Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/editor-ssr-compose.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@react-email/editor': minor
---

Make the email serialization pipeline SSR-capable. `composeReactEmail` now accepts `{ content, extensions }` — a stored TipTap JSON document plus the extension set it was written with — and renders it to email HTML/text in any server environment, no `Editor` instance and no DOM required. The `{ editor }` form keeps working as before; mixing `editor` with `content` or `extensions` is rejected. Extension resolution and schema compilation are cached per extensions-array identity and validated against the array's elements, so a mutated array re-resolves instead of serving stale results; already-resolved arrays (such as `editor.extensionManager.extensions`) are rejected with a pointed error — pass the original extension list the document was created with. A new `format` option (default `true`) skips the Prettier pass when set to `false`, so server send paths that read `unformattedHtml` don't pay for formatting they never use.

`SerializerPlugin`'s `getNodeStyles` and `BaseTemplate` now receive a `ComposeContext` (`{ doc, schema, extensions }`) instead of an `Editor`. When composing with `{ editor }`, the context transparently exposes the live editor's members, so plugins written against the editor-based API keep working unchanged in that mode (`BaseTemplate` additionally still receives the live editor under the deprecated `editor` prop, and `getEmailTheming` accepts both editors and contexts). When composing with `{ content }` there is no editor: reading editor-only members like `extensionManager` or `state` inside serializer hooks throws a descriptive migration error pointing at the context-based API instead of crashing with an opaque `TypeError`.

Also fixes config-object themes (`theme: { extends, styles }`) being silently dropped when serializing documents that were never opened in an editor, adds `getGlobalContentFromJSON` for reading `globalContent` values straight from document JSON, and exports `EmailThemingResult`, the type returned by `getEmailTheming`.
116 changes: 93 additions & 23 deletions apps/docs/editor/api-reference/compose-react-email.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,13 @@ icon: "file-export"
---

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
a 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.

It runs anywhere: pass it a live editor in the browser, or a stored JSON document plus
your extension list on a server — no editor instance and no DOM required.

## Import

```tsx
Expand All @@ -19,21 +22,52 @@ import { composeReactEmail } from '@react-email/editor/core';
## Signature

```tsx
// From a document (works server-side — no editor, no DOM)
async function composeReactEmail(params: {
content: JSONContent;
extensions: Extensions;
preview?: string;
format?: boolean;
}): Promise<{ html: string; text: string; unformattedHtml: string }>;

// From a live editor
async function composeReactEmail(params: {
editor: Editor;
preview: string | null;
}): Promise<{ html: string; text: string }>;
preview?: string;
format?: boolean;
}): Promise<{ html: string; text: string; unformattedHtml: string }>;
```

## Parameters

<ResponseField name="editor" type="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.
<ResponseField name="content" type="JSONContent">
The document to serialize, as TipTap JSON — typically what you persisted from
`editor.getJSON()`. Required unless `editor` is passed. HTML strings are not accepted;
convert HTML first with `generateJSON` from `@tiptap/html`.
</ResponseField>

<ResponseField name="extensions" type="Extensions">
The extension set the document was written with, e.g. `[StarterKit, EmailTheming]`.
Required alongside `content`. Documents containing node types the extension set doesn't
know are rejected with an error. Define the array once at module scope — schema
compilation is cached per extensions array. Pass the original extension list, not an
already-resolved one like `editor.extensionManager.extensions`, which is rejected with
an error.
</ResponseField>

<ResponseField name="editor" type="Editor">
A live TipTap editor instance. Shorthand for passing that editor's document and
extensions. Required unless `content` is passed.
</ResponseField>

<ResponseField name="preview" type="string">
Preview text shown in inbox list views before the email is opened. Omit to skip it.
</ResponseField>

<ResponseField name="preview" type="string | null" required>
Preview text shown in inbox list views before the email is opened. Pass `null` to omit.
<ResponseField name="format" type="boolean" default="true">
When `false`, the Prettier formatting pass is skipped and `html` equals
`unformattedHtml`. Recommended on server send paths, which read `unformattedHtml`
and never need formatted output.
</ResponseField>

## Return value
Expand All @@ -42,10 +76,9 @@ Returns a `Promise` that resolves to an object with:

| Field | Type | Description |
|-------|------|-------------|
| `html` | `string` | Full HTML email string, ready to send |
| `html` | `string` | Prettier-formatted HTML, suited for showing in a source-code view (equals `unformattedHtml` when `format: false`) |
| `text` | `string` | Plain text version for email clients that don't support HTML |

Both are generated in parallel for performance.
| `unformattedHtml` | `string` | Unformatted HTML as produced by `render()` — use this when persisting or sending |

---

Expand All @@ -54,18 +87,21 @@ Both are generated in parallel for performance.
Understanding how `composeReactEmail` works helps you write better custom extensions and
debug rendering issues.

### 1. Extract document and extensions
### 1. Build the compose context

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.
Both call forms normalize into a `ComposeContext` — a plain data object with the document
JSON (`doc`), the compiled ProseMirror `schema`, and the resolved `extensions`. With
`editor`, these come straight off the instance. With `content`, the extension list is
resolved and compiled once (cached per array identity), and the JSON is round-tripped
through the schema so attribute defaults materialize exactly like `editor.getJSON()`.

### 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
- **`getNodeStyles(node, depth, context)`** — returns `React.CSSProperties` for a given node
- **`BaseTemplate({ previewText, children, context })`** — wraps the serialized content in an email structure

The [`EmailTheming`](/editor/features/theming) extension implements this interface. If no
plugin is found, styles default to `{}` and the built-in `DefaultBaseTemplate` is used.
Expand All @@ -74,9 +110,9 @@ plugin is found, styles default to `{}` and the built-in `DefaultBaseTemplate` i

It recursively walks the ProseMirror document. For each node it:

1. **Resolves styles** — calls `serializerPlugin.getNodeStyles(node, depth, editor)` to get
1. **Resolves styles** — calls `serializerPlugin.getNodeStyles(node, depth, context)` to get
theme styles, then merges any inline styles from the node's attributes
Comment on lines +113 to 114

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Custom node renderers can otherwise be written with the expectation that inline styles have already been merged into the style prop. The compose pipeline passes theme styles only; the built-in renderers merge node.attrs.style themselves. Suggest clarifying that this merge is renderer responsibility.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/docs/editor/api-reference/compose-react-email.mdx, line 113:

<comment>Custom node renderers can otherwise be written with the expectation that inline styles have already been merged into the `style` prop. The compose pipeline passes theme styles only; the built-in renderers merge `node.attrs.style` themselves. Suggest clarifying that this merge is renderer responsibility.</comment>

<file context>
@@ -74,9 +110,9 @@ plugin is found, styles default to `{}` and the built-in `DefaultBaseTemplate` i
 It recursively walks the ProseMirror document. For each node it:
 
-1. **Resolves styles** — calls `serializerPlugin.getNodeStyles(node, depth, editor)` to get
+1. **Resolves styles** — calls `serializerPlugin.getNodeStyles(node, depth, context)` 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
</file context>
Suggested change
1. **Resolves styles** — calls `serializerPlugin.getNodeStyles(node, depth, context)` to get
theme styles, then merges any inline styles from the node's attributes
1. **Resolves styles** — calls `serializerPlugin.getNodeStyles(node, depth, context)` to get
theme styles and passes them to the node renderer, which can merge inline styles from its attributes

2. **Renders unknown nodes as `null`** — if the node type isn't registered or isn't an
2. **Renders unknown nodes as `null`** — if the node type isn't an
[`EmailNode`](/editor/api-reference/email-node), it returns `null`
3. **Renders the node** — calls the extension's `renderToReactEmail()` component, passing
`children` (from recursing into child nodes), `style`, `node`, and `extension`
Expand Down Expand Up @@ -156,12 +192,45 @@ are produced in parallel from the final React tree.

## Usage

### Basic export
### Server-side export

Persist `editor.getJSON()` from the browser, then render it to email HTML wherever the
email is actually sent — an API route, a queue worker, a cron job:

```tsx
import { composeReactEmail } from '@react-email/editor/core';
import { StarterKit } from '@react-email/editor/extensions';
import { EmailTheming } from '@react-email/editor/plugins';

const extensions = [StarterKit, EmailTheming];

export async function POST(request: Request) {
const { document } = await request.json();

const { unformattedHtml, text } = await composeReactEmail({
content: document,
extensions,
preview: 'Check out our latest updates!',
format: false,
});

await sendEmail({ html: unformattedHtml, text });
return Response.json({ ok: true });
}
```

<Note>
`content` must be TipTap JSON. If you have HTML instead, convert it first with
[`generateJSON` from `@tiptap/html`](https://tiptap.dev/docs/editor/api/utilities/html),
which also works server-side.
</Note>

### Basic export from an editor

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

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

### With preview text
Expand All @@ -176,7 +245,7 @@ const { html, text } = await composeReactEmail({
});
```

Pass `null` to omit preview text entirely.
Omit it to skip preview text entirely.

### With theming

Expand All @@ -190,7 +259,8 @@ 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 });
const storedDocument = await loadDocumentFromDatabase(); // TipTap JSON
const { html } = await composeReactEmail({ content: storedDocument, extensions });
```

### Full example with export panel
Expand All @@ -208,7 +278,7 @@ function ExportPanel() {
const handleExport = async () => {
if (!editor) return;
setExporting(true);
const result = await composeReactEmail({ editor, preview: null });
const result = await composeReactEmail({ editor });
setHtml(result.html);
setExporting(false);
};
Expand Down
40 changes: 36 additions & 4 deletions apps/docs/editor/features/email-export.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -98,11 +98,42 @@ export function MyEditor() {
}
```

## Exporting on the server

`composeReactEmail` doesn't need an editor or a browser. Pass a stored document
(TipTap JSON, as returned by `editor.getJSON()`) together with the extension set it was
written with, and it renders the same email HTML in Node — API routes, queue workers,
cron jobs, edge functions:

```tsx
import { composeReactEmail } from '@react-email/editor/core';
import { StarterKit } from '@react-email/editor/extensions';
import { EmailTheming } from '@react-email/editor/plugins';

const extensions = [StarterKit, EmailTheming];

const { unformattedHtml, text } = await composeReactEmail({
content: storedDocument, // TipTap JSON from your database
extensions,
preview: 'Check out our latest updates!',
format: false,
});
```

`format: false` skips the Prettier pass that produces the `html` source-view output —
sending reads `unformattedHtml`, so servers shouldn't pay for formatting nobody reads.

The output is byte-identical to exporting the same document from a live editor with the
same extensions. If you have HTML instead of JSON, convert it first with
[`generateJSON` from `@tiptap/html`](https://tiptap.dev/docs/editor/api/utilities/html) —
strings are rejected by `composeReactEmail` so a JSON-stringified document can never be
mistaken for HTML.

## How it works

The `composeReactEmail` function follows this pipeline:

1. **Read** the editor's JSON document
1. **Read** the document — from the `editor`, or from `content` + `extensions`
2. **Traverse** each node and mark in the document tree
3. **Call** `renderToReactEmail()` on each `EmailNode` and `EmailMark` extension
4. **Apply** theme styles via the `SerializerPlugin` (if `EmailTheming` is configured)
Expand All @@ -112,10 +143,11 @@ The `composeReactEmail` function follows this pipeline:
The return value is:

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

// html — Full HTML email string, ready to send
// text — Plain text version for email clients that don't support HTML
// html — Prettier-formatted HTML, for source-code views (skip with format: false)
// text — Plain text version for email clients that don't support HTML
// unformattedHtml — Compact HTML, best for persisting and sending
```

## Preview text
Expand Down
Loading
Loading