Email Template Editor

Technical reference for the Certivant email template editor — a standalone Vite + React app that embeds GrapesJS and Monaco Editor inside the Next.js admin console via iframe and postMessage.

The email template editor lives in apps/email-editor/ as a standalone Vite + React app. It is embedded inside the Next.js admin console via an <iframe> and communicates with the parent page through postMessage.

Why a separate Vite app?

GrapesJS and Monaco Editor are browser-only tools designed for Vite-style build pipelines. Running them inside Next.js/Turbopack caused multiple unresolvable conflicts:

  • GrapesJS CSS contains ::btn — an invalid pseudo-element that Turbopack's CSS linter rejects (no suppression API exists in Turbopack)
  • Monaco Editor spawns Web Workers that Turbopack cannot bundle correctly
  • Both tools use browser-only APIs that conflict with SSR

Moving them to a dedicated Vite app eliminates all of these issues.

File structure

apps/email-editor/
├── package.json            @repo/email-editor
├── tsconfig.json           browser target, no Next.js deps
├── vite.config.ts          React plugin + vite-plugin-monaco-editor
├── index.html              full-viewport, no chrome
└── src/
    ├── main.tsx            React createRoot entry
    ├── App.tsx             GrapesJS init + Monaco modal + postMessage bridge
    └── types/
        └── postMessage.ts  INIT / READY / CHANGE / SAVE protocol

postMessage protocol

All communication between the Next.js parent and the iframe uses a typed postMessage protocol defined in src/types/postMessage.ts.

Parent → iframe

// Sent once when the iframe signals READY
{ type: "INIT"; html: string; variables: string[]; disabled: boolean }

iframe → parent

{ type: "READY"  }                 // GrapesJS fully initialised
{ type: "CHANGE"; html: string }   // canvas mutated (every edit)
{ type: "SAVE";   html: string }   // user clicked Apply in code modal

Sequence

1. iframe loads  → sends READY (page JS ready)
2. parent        → sends INIT  (passes current HTML + variables)
3. iframe        → inits GrapesJS with the received HTML
4. iframe        → sends READY again (GrapesJS fully up)
5. user edits    → iframe sends CHANGE on every canvas mutation
6. user clicks Apply in Monaco modal → iframe sends SAVE

Next.js side — GrapesJsEmailEditor.tsx

The Next.js component is a thin iframe wrapper (apps/web/modules/saas/admin/components/communications/GrapesJsEmailEditor.tsx). It has zero GrapesJS or Monaco dependencies.

const EDITOR_URL =
  process.env.NEXT_PUBLIC_EMAIL_EDITOR_URL ?? "http://localhost:5173";

// Sends INIT when iframe signals READY
// Calls onChange() on CHANGE messages
// Calls onSave()   on SAVE messages

Environment variables

VariableWherePurpose
NEXT_PUBLIC_EMAIL_EDITOR_URLapps/web/.env.localURL of the Vite app
VITE_PARENT_ORIGINapps/email-editor/.envTightens postMessage origin in prod

Development (.env.local in apps/web/)

NEXT_PUBLIC_EMAIL_EDITOR_URL=http://localhost:5173

Production

NEXT_PUBLIC_EMAIL_EDITOR_URL=https://email-editor.certivant.com

Running locally

# From repo root — starts both servers via Turborepo
pnpm dev

# Or individually
pnpm --filter @repo/email-editor dev   # Vite  → http://localhost:5173
pnpm --filter @repo/web dev            # Next.js → http://localhost:3005

Building for production

pnpm build
# Turborepo builds @repo/email-editor first (turbo.json: @repo/web#build
# dependsOn @repo/email-editor#build), then @repo/web.

Deploy apps/email-editor/dist/ to any static host (Cloudflare Pages, S3, Netlify). Set NEXT_PUBLIC_EMAIL_EDITOR_URL in your Next.js production environment to the deployed URL.

Security

  • The iframe runs with sandbox="allow-scripts allow-downloads allow-popups". allow-same-origin is intentionally omitted — this prevents the iframe from accessing the parent's cookies or localStorage.
  • The postMessage handler in GrapesJsEmailEditor.tsx validates event.origin against EDITOR_ORIGIN before processing any message.
  • The postToParent function in App.tsx uses VITE_PARENT_ORIGIN (defaults to "*" in dev, should be set to the exact Next.js origin in production).

Dependencies

PackageVersionPurpose
grapesjs^0.21.13Drag-and-drop email canvas
grapesjs-preset-newsletter^1.0.2Newsletter blocks + toolbar layout
monaco-editor^0.55.1HTML code editor in the code modal
vite-plugin-monaco-editor^1.1.0Bundles Monaco Web Workers correctly
@vitejs/plugin-react^4.3.4React JSX transform + HMR