Skip to content

Commit 6ca1bed

Browse files
committed
feat: make editor extensions ssr
1 parent a500725 commit 6ca1bed

20 files changed

Lines changed: 2001 additions & 96 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@react-email/editor': minor
3+
---
4+
5+
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.
6+
7+
`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`.
8+
9+
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`.

β€Žapps/docs/editor/api-reference/compose-react-email.mdxβ€Ž

Lines changed: 93 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,13 @@ icon: "file-export"
66
---
77

88
The `composeReactEmail` function is the core of the editor's email export system. It takes
9-
the editor's document tree, walks every node and mark, calls each extension's
9+
a document tree, walks every node and mark, calls each extension's
1010
`renderToReactEmail()` method, applies theme styles, wraps everything in an email-ready
1111
template, and produces both HTML and plain text output.
1212

13+
It runs anywhere: pass it a live editor in the browser, or a stored JSON document plus
14+
your extension list on a server β€” no editor instance and no DOM required.
15+
1316
## Import
1417

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

2124
```tsx
25+
// From a document (works server-side β€” no editor, no DOM)
26+
async function composeReactEmail(params: {
27+
content: JSONContent;
28+
extensions: Extensions;
29+
preview?: string;
30+
format?: boolean;
31+
}): Promise<{ html: string; text: string; unformattedHtml: string }>;
32+
33+
// From a live editor
2234
async function composeReactEmail(params: {
2335
editor: Editor;
24-
preview: string | null;
25-
}): Promise<{ html: string; text: string }>;
36+
preview?: string;
37+
format?: boolean;
38+
}): Promise<{ html: string; text: string; unformattedHtml: string }>;
2639
```
2740

2841
## Parameters
2942

30-
<ResponseField name="editor" type="Editor" required>
31-
The TipTap editor instance. The function reads the editor's JSON document and walks through
32-
each registered extension to serialize nodes and marks.
43+
<ResponseField name="content" type="JSONContent">
44+
The document to serialize, as TipTap JSON β€” typically what you persisted from
45+
`editor.getJSON()`. Required unless `editor` is passed. HTML strings are not accepted;
46+
convert HTML first with `generateJSON` from `@tiptap/html`.
47+
</ResponseField>
48+
49+
<ResponseField name="extensions" type="Extensions">
50+
The extension set the document was written with, e.g. `[StarterKit, EmailTheming]`.
51+
Required alongside `content`. Documents containing node types the extension set doesn't
52+
know are rejected with an error. Define the array once at module scope β€” schema
53+
compilation is cached per extensions array. Pass the original extension list, not an
54+
already-resolved one like `editor.extensionManager.extensions`, which is rejected with
55+
an error.
56+
</ResponseField>
57+
58+
<ResponseField name="editor" type="Editor">
59+
A live TipTap editor instance. Shorthand for passing that editor's document and
60+
extensions. Required unless `content` is passed.
61+
</ResponseField>
62+
63+
<ResponseField name="preview" type="string">
64+
Preview text shown in inbox list views before the email is opened. Omit to skip it.
3365
</ResponseField>
3466

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

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

4377
| Field | Type | Description |
4478
|-------|------|-------------|
45-
| `html` | `string` | Full HTML email string, ready to send |
79+
| `html` | `string` | Prettier-formatted HTML, suited for showing in a source-code view (equals `unformattedHtml` when `format: false`) |
4680
| `text` | `string` | Plain text version for email clients that don't support HTML |
47-
48-
Both are generated in parallel for performance.
81+
| `unformattedHtml` | `string` | Unformatted HTML as produced by `render()` β€” use this when persisting or sending |
4982

5083
---
5184

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

57-
### 1. Extract document and extensions
90+
### 1. Build the compose context
5891

59-
The function reads the editor's JSON document (via `editor.getJSON()`) and collects all
60-
registered extensions into a name-to-extension map for fast lookup.
92+
Both call forms normalize into a `ComposeContext` β€” a plain data object with the document
93+
JSON (`doc`), the compiled ProseMirror `schema`, and the resolved `extensions`. With
94+
`editor`, these come straight off the instance. With `content`, the extension list is
95+
resolved and compiled once (cached per array identity), and the JSON is round-tripped
96+
through the schema so attribute defaults materialize exactly like `editor.getJSON()`.
6197

6298
### 2. Find the SerializerPlugin
6399

64100
It searches extensions for one that provides a `SerializerPlugin` β€” an interface with two
65101
methods:
66102

67-
- **`getNodeStyles(node, depth, editor)`** β€” returns `React.CSSProperties` for a given node
68-
- **`BaseTemplate({ previewText, children, editor })`** β€” wraps the serialized content in an email structure
103+
- **`getNodeStyles(node, depth, context)`** β€” returns `React.CSSProperties` for a given node
104+
- **`BaseTemplate({ previewText, children, context })`** β€” wraps the serialized content in an email structure
69105

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

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

77-
1. **Resolves styles** β€” calls `serializerPlugin.getNodeStyles(node, depth, editor)` to get
113+
1. **Resolves styles** β€” calls `serializerPlugin.getNodeStyles(node, depth, context)` to get
78114
theme styles, then merges any inline styles from the node's attributes
79-
2. **Renders unknown nodes as `null`** β€” if the node type isn't registered or isn't an
115+
2. **Renders unknown nodes as `null`** β€” if the node type isn't an
80116
[`EmailNode`](/editor/api-reference/email-node), it returns `null`
81117
3. **Renders the node** β€” calls the extension's `renderToReactEmail()` component, passing
82118
`children` (from recursing into child nodes), `style`, `node`, and `extension`
@@ -156,12 +192,45 @@ are produced in parallel from the final React tree.
156192

157193
## Usage
158194

159-
### Basic export
195+
### Server-side export
196+
197+
Persist `editor.getJSON()` from the browser, then render it to email HTML wherever the
198+
email is actually sent β€” an API route, a queue worker, a cron job:
199+
200+
```tsx
201+
import { composeReactEmail } from '@react-email/editor/core';
202+
import { StarterKit } from '@react-email/editor/extensions';
203+
import { EmailTheming } from '@react-email/editor/plugins';
204+
205+
const extensions = [StarterKit, EmailTheming];
206+
207+
export async function POST(request: Request) {
208+
const { document } = await request.json();
209+
210+
const { unformattedHtml, text } = await composeReactEmail({
211+
content: document,
212+
extensions,
213+
preview: 'Check out our latest updates!',
214+
format: false,
215+
});
216+
217+
await sendEmail({ html: unformattedHtml, text });
218+
return Response.json({ ok: true });
219+
}
220+
```
221+
222+
<Note>
223+
`content` must be TipTap JSON. If you have HTML instead, convert it first with
224+
[`generateJSON` from `@tiptap/html`](https://tiptap.dev/docs/editor/api/utilities/html),
225+
which also works server-side.
226+
</Note>
227+
228+
### Basic export from an editor
160229

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

164-
const { html, text } = await composeReactEmail({ editor, preview: null });
233+
const { html, text } = await composeReactEmail({ editor });
165234
```
166235

167236
### With preview text
@@ -176,7 +245,7 @@ const { html, text } = await composeReactEmail({
176245
});
177246
```
178247

179-
Pass `null` to omit preview text entirely.
248+
Omit it to skip preview text entirely.
180249

181250
### With theming
182251

@@ -190,7 +259,8 @@ import { EmailTheming } from '@react-email/editor/plugins';
190259
const extensions = [StarterKit, EmailTheming.configure({ theme: 'basic' })];
191260

192261
// Theme styles are injected automatically β€” no extra config needed
193-
const { html } = await composeReactEmail({ editor, preview: null });
262+
const storedDocument = await loadDocumentFromDatabase(); // TipTap JSON
263+
const { html } = await composeReactEmail({ content: storedDocument, extensions });
194264
```
195265

196266
### Full example with export panel
@@ -208,7 +278,7 @@ function ExportPanel() {
208278
const handleExport = async () => {
209279
if (!editor) return;
210280
setExporting(true);
211-
const result = await composeReactEmail({ editor, preview: null });
281+
const result = await composeReactEmail({ editor });
212282
setHtml(result.html);
213283
setExporting(false);
214284
};

β€Žapps/docs/editor/features/email-export.mdxβ€Ž

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -98,11 +98,42 @@ export function MyEditor() {
9898
}
9999
```
100100

101+
## Exporting on the server
102+
103+
`composeReactEmail` doesn't need an editor or a browser. Pass a stored document
104+
(TipTap JSON, as returned by `editor.getJSON()`) together with the extension set it was
105+
written with, and it renders the same email HTML in Node β€” API routes, queue workers,
106+
cron jobs, edge functions:
107+
108+
```tsx
109+
import { composeReactEmail } from '@react-email/editor/core';
110+
import { StarterKit } from '@react-email/editor/extensions';
111+
import { EmailTheming } from '@react-email/editor/plugins';
112+
113+
const extensions = [StarterKit, EmailTheming];
114+
115+
const { unformattedHtml, text } = await composeReactEmail({
116+
content: storedDocument, // TipTap JSON from your database
117+
extensions,
118+
preview: 'Check out our latest updates!',
119+
format: false,
120+
});
121+
```
122+
123+
`format: false` skips the Prettier pass that produces the `html` source-view output β€”
124+
sending reads `unformattedHtml`, so servers shouldn't pay for formatting nobody reads.
125+
126+
The output is byte-identical to exporting the same document from a live editor with the
127+
same extensions. If you have HTML instead of JSON, convert it first with
128+
[`generateJSON` from `@tiptap/html`](https://tiptap.dev/docs/editor/api/utilities/html) β€”
129+
strings are rejected by `composeReactEmail` so a JSON-stringified document can never be
130+
mistaken for HTML.
131+
101132
## How it works
102133

103134
The `composeReactEmail` function follows this pipeline:
104135

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

114145
```tsx
115-
const { html, text } = await composeReactEmail({ editor, preview: null });
146+
const { html, text, unformattedHtml } = await composeReactEmail({ editor });
116147

117-
// html β€” Full HTML email string, ready to send
118-
// text β€” Plain text version for email clients that don't support HTML
148+
// html β€” Prettier-formatted HTML, for source-code views (skip with format: false)
149+
// text β€” Plain text version for email clients that don't support HTML
150+
// unformattedHtml β€” Compact HTML, best for persisting and sending
119151
```
120152

121153
## Preview text

0 commit comments

Comments
Β (0)