Documentation Index Fetch the complete documentation index at: https://react.email/docs/llms.txt
Use this file to discover all available pages before exploring further.
Creating a custom node
Custom extensions use EmailNode, which extends TipTap’s Node with one extra required
method: renderToReactEmail(). Use parseHTML() to recognize pasted or imported HTML,
renderHTML() to control how the node appears in the editor, and renderToReactEmail() to
control how it is exported by composeReactEmail.
Here’s a complete example of a custom “Callout” node that renders as a highlighted block:
import { EmailNode } from '@react-email/editor/core' ;
import { mergeAttributes } from '@tiptap/core' ;
const Callout = EmailNode. create ({
name: 'callout' ,
group: 'block' ,
content: 'inline*' ,
parseHTML () {
return [{ tag: 'div[data-callout]' }];
},
renderHTML ({ HTMLAttributes }) {
return [
'div' ,
mergeAttributes (HTMLAttributes, {
'data-callout' : '' ,
style:
'padding: 12px 16px; background: #f4f4f5; border-left: 3px solid #1c1c1c; border-radius: 4px; margin: 8px 0;' ,
}),
0 ,
];
},
renderToReactEmail ({ children , style }) {
return (
< div
style = { {
... style,
padding: '12px 16px' ,
backgroundColor: '#f4f4f5' ,
borderLeft: '3px solid #1c1c1c' ,
borderRadius: '4px' ,
margin: '8px 0' ,
} }
>
{ children }
</ div >
);
},
});
Registering the extension
Add your custom extension to the extensions array alongside StarterKit:
const extensions = [StarterKit, Callout];
Inserting custom nodes
Use the editor’s insertContent command to programmatically insert your custom node:
import { useCurrentEditor } from '@tiptap/react' ;
function Toolbar () {
const { editor } = useCurrentEditor ();
if ( ! editor) return null ;
return (
< button
onClick = { () =>
editor
. chain ()
. focus ()
. insertContent ({
type: 'callout' ,
content: [{ type: 'text' , text: 'New callout' }],
})
. run ()
}
>
Insert Callout
</ button >
);
}
Complete example
Here’s the full editor setup with the custom Callout extension, a toolbar, and a bubble menu:
import { EmailNode } from '@react-email/editor/core' ;
import { StarterKit } from '@react-email/editor/extensions' ;
import { BubbleMenu } from '@react-email/editor/ui' ;
import { mergeAttributes } from '@tiptap/core' ;
import { EditorProvider, useCurrentEditor } from '@tiptap/react' ;
import { Info } from 'lucide-react' ;
const Callout = EmailNode. create ({
name: 'callout' ,
group: 'block' ,
content: 'inline*' ,
parseHTML () {
return [{ tag: 'div[data-callout]' }];
},
renderHTML ({ HTMLAttributes }) {
return [
'div' ,
mergeAttributes (HTMLAttributes, {
'data-callout' : '' ,
style:
'padding: 12px 16px; background: #f4f4f5; border-left: 3px solid #1c1c1c; border-radius: 4px; margin: 8px 0;' ,
}),
0 ,
];
},
renderToReactEmail ({ children , style }) {
return (
< div
style = { {
... style,
padding: '12px 16px' ,
backgroundColor: '#f4f4f5' ,
borderLeft: '3px solid #1c1c1c' ,
borderRadius: '4px' ,
margin: '8px 0' ,
} }
>
{ children }
</ div >
);
},
});
const extensions = [StarterKit, Callout];
const content = {
type: 'doc' ,
content: [
{
type: 'paragraph' ,
content: [
{
type: 'text' ,
text: 'This editor includes a custom Callout node. Use the toolbar to insert one.' ,
},
],
},
{
type: 'callout' ,
content: [
{ type: 'text' , text: 'This is a callout block — a custom extension!' },
],
},
],
};
function Toolbar () {
const { editor } = useCurrentEditor ();
if ( ! editor) return null ;
return (
< button
onClick = { () =>
editor
. chain ()
. focus ()
. insertContent ({
type: 'callout' ,
content: [{ type: 'text' , text: 'New callout' }],
})
. run ()
}
>
< Info size = { 16 } />
Insert Callout
</ button >
);
}
export function MyEditor () {
return (
< EditorProvider
extensions = { extensions }
content = { content }
slotBefore = {< Toolbar />}
>
< BubbleMenu />
</ EditorProvider >
);
}
Wrapping existing TipTap extensions
Both EmailNode and EmailMark provide a .from() method that wraps an existing TipTap
extension with email serialization support. This is useful when you want to reuse a community
TipTap extension and add email export support without rewriting it.
import { EmailNode } from '@react-email/editor/core' ;
import { Node } from '@tiptap/core' ;
const MyTipTapNode = Node. create ({ /* ... */ });
const MyEmailNode = EmailNode. from (MyTipTapNode, ({ children , style }) => {
return < div style = { style }>{ children }</ div > ;
});
import { EmailMark } from '@react-email/editor/core' ;
import { Mark } from '@tiptap/core' ;
const MyTipTapMark = Mark. create ({ /* ... */ });
const MyEmailMark = EmailMark. from (MyTipTapMark, ({ children , style }) => {
return < mark style = { { ... style, backgroundColor: '#fef08a' } }>{ children }</ mark > ;
});
For full API details on all methods (create, from, configure, extend), see the
EmailNode and EmailMark
reference pages.
Both EmailNode and EmailMark support TipTap’s standard customization methods:
// Configure options
const CustomHeading = Heading. configure ({ levels: [ 1 , 2 ] });
// Extend with additional behavior
const CustomParagraph = Paragraph. extend ({
addKeyboardShortcuts () {
return {
'Mod-Shift-p' : () => this .editor.commands. setParagraph (),
};
},
});
Examples
See custom extensions in action with a runnable example:
Custom Extensions Custom Callout node with EmailNode.create, toolbar insertion, and bubble menu.