diff --git a/packages/editor/src/core/serializer/compose-react-email.spec.tsx b/packages/editor/src/core/serializer/compose-react-email.spec.tsx
index 93df6eeb96..87f8da5026 100644
--- a/packages/editor/src/core/serializer/compose-react-email.spec.tsx
+++ b/packages/editor/src/core/serializer/compose-react-email.spec.tsx
@@ -358,6 +358,64 @@ describe('StarterKit node wrappers', () => {
});
});
+describe('Trailing empty paragraph stripping', () => {
+ it('strips trailing empty paragraph from container ending with button', async () => {
+ const content: JSONContent = {
+ type: 'doc',
+ content: [
+ {
+ type: 'container',
+ content: [
+ {
+ type: 'button',
+ attrs: { href: 'https://example.com' },
+ content: [{ type: 'text', text: 'Click me' }],
+ },
+ { type: 'paragraph' },
+ ],
+ },
+ ],
+ };
+
+ const editor = createEditorWithContent(content);
+ const result = await composeReactEmail({ editor, preview: '' });
+
+ const buttonIndex = result.html.indexOf('Click me');
+ const lastParagraph = result.html.lastIndexOf('
{
+ const content: JSONContent = {
+ type: 'doc',
+ content: [
+ {
+ type: 'container',
+ content: [
+ {
+ type: 'button',
+ attrs: { href: 'https://example.com' },
+ content: [{ type: 'text', text: 'Click me' }],
+ },
+ {
+ type: 'paragraph',
+ content: [{ type: 'text', text: 'Footer text' }],
+ },
+ ],
+ },
+ ],
+ };
+
+ const editor = createEditorWithContent(content);
+ const result = await composeReactEmail({ editor, preview: '' });
+
+ expect(result.html).toContain('Click me');
+ expect(result.html).toContain('Footer text');
+ });
+});
+
describe('Button and image reset styles', () => {
it('should include display:inline-block on buttons with the basic theme', async () => {
const content = docWithGlobalContent(
diff --git a/packages/editor/src/core/serializer/compose-react-email.tsx b/packages/editor/src/core/serializer/compose-react-email.tsx
index e9dbd447fa..4e172a37e1 100644
--- a/packages/editor/src/core/serializer/compose-react-email.tsx
+++ b/packages/editor/src/core/serializer/compose-react-email.tsx
@@ -2,6 +2,7 @@ import { pretty, render, toPlainText } from '@react-email/components';
import type { Editor, JSONContent } from '@tiptap/core';
import type { MarkType, Schema } from '@tiptap/pm/model';
import { inlineCssToJs } from '../../utils/styles';
+import { stripTrailingEmptyParagraphs } from '../../utils/strip-trailing-empty-paragraphs';
import { DefaultBaseTemplate } from './default-base-template';
import { EmailMark } from './email-mark';
import { EmailNode } from './email-node';
@@ -45,7 +46,7 @@ export const composeReactEmail = async ({
editor: Editor;
preview?: string;
}): Promise => {
- const data = editor.getJSON();
+ const data = stripTrailingEmptyParagraphs(editor.getJSON());
const extensions = editor.extensionManager.extensions;
const serializerPlugin = extensions
diff --git a/packages/editor/src/utils/strip-trailing-empty-paragraphs.spec.ts b/packages/editor/src/utils/strip-trailing-empty-paragraphs.spec.ts
new file mode 100644
index 0000000000..eca26f2a16
--- /dev/null
+++ b/packages/editor/src/utils/strip-trailing-empty-paragraphs.spec.ts
@@ -0,0 +1,180 @@
+import type { JSONContent } from '@tiptap/core';
+import { describe, expect, it } from 'vitest';
+import { stripTrailingEmptyParagraphs } from './strip-trailing-empty-paragraphs';
+
+describe('stripTrailingEmptyParagraphs', () => {
+ it('removes trailing empty paragraph from container', () => {
+ const input: JSONContent = {
+ type: 'doc',
+ content: [
+ {
+ type: 'container',
+ content: [
+ {
+ type: 'button',
+ attrs: { href: 'https://example.com' },
+ content: [{ type: 'text', text: 'Click' }],
+ },
+ { type: 'paragraph' },
+ ],
+ },
+ ],
+ };
+
+ const result = stripTrailingEmptyParagraphs(input);
+ const container = result.content![0]!;
+ expect(container.content).toHaveLength(1);
+ expect(container.content![0]!.type).toBe('button');
+ });
+
+ it('removes trailing empty paragraph with empty content array', () => {
+ const input: JSONContent = {
+ type: 'doc',
+ content: [
+ {
+ type: 'container',
+ content: [
+ {
+ type: 'heading',
+ content: [{ type: 'text', text: 'Title' }],
+ },
+ { type: 'paragraph', content: [] },
+ ],
+ },
+ ],
+ };
+
+ const result = stripTrailingEmptyParagraphs(input);
+ const container = result.content![0]!;
+ expect(container.content).toHaveLength(1);
+ expect(container.content![0]!.type).toBe('heading');
+ });
+
+ it('preserves trailing paragraph with content', () => {
+ const input: JSONContent = {
+ type: 'doc',
+ content: [
+ {
+ type: 'container',
+ content: [
+ {
+ type: 'button',
+ attrs: { href: 'https://example.com' },
+ content: [{ type: 'text', text: 'Click' }],
+ },
+ {
+ type: 'paragraph',
+ content: [{ type: 'text', text: 'Some text' }],
+ },
+ ],
+ },
+ ],
+ };
+
+ const result = stripTrailingEmptyParagraphs(input);
+ const container = result.content![0]!;
+ expect(container.content).toHaveLength(2);
+ expect(container.content![1]!.type).toBe('paragraph');
+ });
+
+ it('keeps single empty paragraph in container to avoid empty content', () => {
+ const input: JSONContent = {
+ type: 'doc',
+ content: [
+ {
+ type: 'container',
+ content: [{ type: 'paragraph' }],
+ },
+ ],
+ };
+
+ const result = stripTrailingEmptyParagraphs(input);
+ const container = result.content![0]!;
+ expect(container.content).toHaveLength(1);
+ expect(container.content![0]!.type).toBe('paragraph');
+ });
+
+ it('does not strip trailing paragraph from non-container nodes', () => {
+ const input: JSONContent = {
+ type: 'doc',
+ content: [
+ {
+ type: 'section',
+ content: [
+ {
+ type: 'button',
+ attrs: { href: 'https://example.com' },
+ content: [{ type: 'text', text: 'Click' }],
+ },
+ { type: 'paragraph' },
+ ],
+ },
+ ],
+ };
+
+ const result = stripTrailingEmptyParagraphs(input);
+ const section = result.content![0]!;
+ expect(section.content).toHaveLength(2);
+ });
+
+ it('handles nested containers', () => {
+ const input: JSONContent = {
+ type: 'doc',
+ content: [
+ {
+ type: 'container',
+ content: [
+ {
+ type: 'paragraph',
+ content: [{ type: 'text', text: 'Hello' }],
+ },
+ { type: 'paragraph' },
+ ],
+ },
+ ],
+ };
+
+ const result = stripTrailingEmptyParagraphs(input);
+ const container = result.content![0]!;
+ expect(container.content).toHaveLength(1);
+ expect(container.content![0]!.type).toBe('paragraph');
+ expect(container.content![0]!.content![0]!.text).toBe('Hello');
+ });
+
+ it('handles doc with no content', () => {
+ const input: JSONContent = { type: 'doc' };
+ const result = stripTrailingEmptyParagraphs(input);
+ expect(result).toEqual({ type: 'doc' });
+ });
+
+ it('handles doc with empty content array', () => {
+ const input: JSONContent = { type: 'doc', content: [] };
+ const result = stripTrailingEmptyParagraphs(input);
+ expect(result).toEqual({ type: 'doc', content: [] });
+ });
+
+ it('does not strip non-trailing empty paragraphs from container', () => {
+ const input: JSONContent = {
+ type: 'doc',
+ content: [
+ {
+ type: 'container',
+ content: [
+ { type: 'paragraph' },
+ {
+ type: 'button',
+ attrs: { href: 'https://example.com' },
+ content: [{ type: 'text', text: 'Click' }],
+ },
+ ],
+ },
+ ],
+ };
+
+ const result = stripTrailingEmptyParagraphs(input);
+ const container = result.content![0]!;
+ expect(container.content).toHaveLength(2);
+ expect(container.content![0]!.type).toBe('paragraph');
+ expect(container.content![1]!.type).toBe('button');
+ });
+});
diff --git a/packages/editor/src/utils/strip-trailing-empty-paragraphs.ts b/packages/editor/src/utils/strip-trailing-empty-paragraphs.ts
new file mode 100644
index 0000000000..c0da1d2eb1
--- /dev/null
+++ b/packages/editor/src/utils/strip-trailing-empty-paragraphs.ts
@@ -0,0 +1,32 @@
+import type { JSONContent } from '@tiptap/core';
+
+const isEmptyParagraph = (node: JSONContent): boolean =>
+ node.type === 'paragraph' && (!node.content || node.content.length === 0);
+
+export const stripTrailingEmptyParagraphs = (
+ content: JSONContent,
+): JSONContent => {
+ if (!content.content || content.content.length === 0) {
+ return content;
+ }
+
+ const processedChildren = content.content.map((child) =>
+ stripTrailingEmptyParagraphs(child),
+ );
+
+ if (content.type !== 'container') {
+ return { ...content, content: processedChildren };
+ }
+
+ const lastChild = processedChildren[processedChildren.length - 1];
+ if (!lastChild || !isEmptyParagraph(lastChild)) {
+ return { ...content, content: processedChildren };
+ }
+
+ const trimmed = processedChildren.slice(0, -1);
+
+ return {
+ ...content,
+ content: trimmed.length > 0 ? trimmed : processedChildren,
+ };
+};