React
A Vite React app with a small Express route that signs the user id.
A React single-page app cannot hold the Plugin Secret, so signing happens in a separate Express server. The React component fetches the signed values and renders the iframe. This guide uses Vite, but the component works the same in any React setup.
Before you start
Follow the first three steps of the quickstart: create a plugin, add localhost:5173 to its allowed domains, and copy the Plugin ID and Secret.
Configuration
MAILEDITOR_PLUGIN_ID=9f3c1e7a-4b21-4c8e-9a77-1d2f5b6c8e10
MAILEDITOR_PLUGIN_SECRET=your_plugin_secret
MAILEDITOR_EDITOR_BASE_URL=https://maileditor.net
MAILEDITOR_PLUGIN_VERSION=1.0.0Do not prefix these with VITE_. Vite would then inline the secret into the browser bundle.
Sign on the server
Install Express (npm install express). Vite projects already have "type": "module" in package.json, which the import syntax below needs.
import express from 'express'
import { createHmac } from 'node:crypto'
const app = express()
app.get('/api/auth', (req, res) => {
const user = req.query.user
if (typeof user !== 'string' || user === '') {
return res.status(400).json({ error: 'user is required' })
}
const expires = Math.floor(Date.now() / 1000) + 300
const payload = `v1|${Buffer.byteLength(user)}:${user}|${expires}`
const signature = createHmac('sha256', process.env.MAILEDITOR_PLUGIN_SECRET)
.update(payload)
.digest('hex')
res.json({
externalUserId: user,
identityExpires: expires,
identitySignature: signature,
pluginId: process.env.MAILEDITOR_PLUGIN_ID,
pluginVersion: process.env.MAILEDITOR_PLUGIN_VERSION,
editorBaseUrl: process.env.MAILEDITOR_EDITOR_BASE_URL,
})
})
app.listen(3001)Start it with node --env-file=.env server.js. In a real app, take the user id from the caller's session instead of trusting the user query parameter.
Proxy /api to the server so the page and the API share one origin during development:
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
proxy: { '/api': 'http://localhost:3001' },
},
})Render the iframe
import { useEffect, useState } from 'react'
export function Editor({ userId }: { userId: string }) {
const [src, setSrc] = useState<string>()
useEffect(() => {
fetch(`/api/auth?user=${encodeURIComponent(userId)}`)
.then((res) => res.json())
.then((auth) => {
const params = new URLSearchParams({
external_user_id: auth.externalUserId,
identity_expires: String(auth.identityExpires),
identity_signature: auth.identitySignature,
plugin_version: auth.pluginVersion,
})
setSrc(`${auth.editorBaseUrl}/plugin-editor/v1/${auth.pluginId}?${params}`)
})
}, [userId])
if (!src) return null
return (
<iframe
src={src}
title="Email editor"
allow="clipboard-write; fullscreen"
style={{ width: '100%', height: '100vh', border: 0 }}
/>
)
}Use it anywhere: <Editor userId="customer-user-4471" />.
In production, serve the built dist folder from the same Express app or route /api to it from your reverse proxy, and add the production origin to the plugin's allowed domains.
Next steps
- Add
template_idto the query string to open one of the user's saved templates. See Embedding the editor. - If the iframe shows "Plugin editor unavailable", read
result.errorin thesessionresponse. See Error codes.

.png)