Your users want to design their own emails. You don't want to spend months building an editor.
In this post we add a complete drag and drop email builder to a fresh Vite + React app, step by step. It's the written version of a 9 minute video where every step is filmed for real.
🎥 Watch the video: https://www.linkedin.com/feed/update/urn:li:activity:7519114078284562432
What you need: a React app (ours is a fresh Vite app) and Node 18 or newer.
1. Create an account and a token
Sign up at mailkaro.com. Sign in is passwordless: every sign in uses a one time code sent to your email, and an account can only be signed in on one device at a time.
Then open Embed & API, click Generate new token, and choose which features the token exposes, for example:
- Save, export and history
- Uploads and assets
- AI Assistant
You get two keys:
| Key | Starts with | Where it goes |
|---|---|---|
| Publishable | pk_live_ |
your frontend |
| Secret | sk_live_ |
your server only, never in frontend code |
2. Install and render
npm install react-email-craft
Put the publishable key in .env.local:
VITE_MAILKARO_KEY=pk_live_...
Then render the editor:
import EmailEditor from "react-email-craft";
export default function App() {
return (
<EmailEditor
token={import.meta.env.VITE_MAILKARO_KEY}
style={{ height: "100vh" }}
/>
);
}
That's the full editor: the canvas in the middle, blocks and settings on the right.
Tip: Vite's starter
index.csscentres everything in a narrow box. Replace it withbody { margin: 0; }so the editor gets the whole page.Next.js: add
"use client"at the top of the file, or import the editor withssr: false.
3. Save and reopen a design
Add a ref and two buttons. exportHtml hands you the design (JSON describing everything on the canvas) and the HTML. Store the design: that's what reopens the email later.
import { useRef } from "react";
import EmailEditor from "react-email-craft";
export default function App() {
const ref = useRef(null);
const save = () =>
ref.current.editor.exportHtml(({ design, html }) => {
localStorage.setItem("design", JSON.stringify(design)); // your database in a real app
});
const open = () => {
const saved = localStorage.getItem("design");
if (saved) ref.current.editor.loadDesign(JSON.parse(saved));
};
return (
<>
<button onClick={save}>Save</button>
<button onClick={open}>Open</button>
<EmailEditor ref={ref} token={import.meta.env.VITE_MAILKARO_KEY} style={{ height: "90vh" }} />
</>
);
}
4. Merge tags
Each merge tag has a label, the value your email service fills in, and a sample:
<EmailEditor
ref={ref}
token={import.meta.env.VITE_MAILKARO_KEY}
mergeTags={{
first_name: { label: "First name", value: "{{first_name}}", sample: "Alex" },
company: { label: "Company", value: "{{company}}", sample: "Acme" },
}}
/>
In a text block, your users pick Insert merge tag. The tag lands as a labelled pill, so they can see exactly where each field goes.
5. Render email-safe HTML on your server
Sending needs finished HTML, and you can make it on your own server from the saved design:
npm install email-craft-sdk express
import express from "express";
import { renderDesign, renderMjml } from "email-craft-sdk";
const app = express();
app.use(express.json({ limit: "2mb" }));
app.post("/render", (req, res) => {
const html = renderDesign(req.body.design, { fullDocument: true });
const mjml = renderMjml(req.body.design);
res.json({ html, mjml });
});
app.listen(3001, () => console.log("Server on http://localhost:3001"));
There's no headless browser, no network call, and no key needed. It runs inside your backend.
6. Decide what each user can do
The plan unlocks when both keys work together: the publishable key in the browser, and the secret key on your server. Add one route:
import { Mailkaro } from "email-craft-sdk";
const mailkaro = Mailkaro.fromEnv(); // reads MAILKARO_SERVER_KEY (sk_live_...)
app.get("/mailkaro/embed-runtime", async (_req, res) => {
res.json(await mailkaro.resolveRuntime());
});
Point the editor at it with runtimeUrl:
<EmailEditor
ref={ref}
token={import.meta.env.VITE_MAILKARO_KEY}
runtimeUrl="/mailkaro/embed-runtime"
/* ...mergeTags, style */
/>
In development, let Vite forward these routes to Node, so they run on the same origin as your app:
export default defineConfig({
plugins: [react()],
server: {
proxy: { "/mailkaro": "http://localhost:3001", "/render": "http://localhost:3001" },
},
});
Your server answers, and the secret key never leaves it.
7. Your own storage and your own AI
Add your S3 bucket and AI provider key to the server's .env, then three small routes:
import multer from "multer";
app.post("/mailkaro/ai", async (req, res) => {
res.json(await mailkaro.ai(req.body.method, req.body.input));
});
const upload = multer();
app.post("/mailkaro/upload", upload.single("file"), async (req, res) => {
res.json(await mailkaro.uploadAsset({
buffer: req.file.buffer,
filename: req.file.originalname,
contentType: req.file.mimetype,
scopeId: "demo", // your tenant or workspace id
}));
});
app.get("/mailkaro/assets", async (_req, res) => {
res.json(await mailkaro.listAssets({ scopeId: "demo" }));
});
Now the image library has an Amazon S3 tab with your bucket's images, and new uploads go straight into it. The AI assistant runs on your own key and builds emails right on the canvas. The keys stay on the server, and the browser never sees them.
The whole loop
- One token
- One component
- Two calls to save and reopen
- One function to render on your server
- One route that decides what each user can do
The free plan covers the editor itself. Pro unlocks storage, AI and more, and the first month is free with no card.
- Try it without signing up: mailkaro.com/demo
- Docs: mailkaro.com/docs
Next in the series: the same setup in Angular, Vue and plain JavaScript.
Top comments (0)