---
title: Quick start
description: Add the Mail Quilt email editor to your app with a bundler, from a CDN with no build step, or in React, then save emails and export them as HTML.
---

Pick the way your app loads JavaScript. Each example does the same four things: it opens an email, saves every change in the browser, shows the reason if the editor refuses an email, and exports the email as HTML. Mail Quilt's tests install each one against the built packages and run it in a browser, so the code works as written.

`starterEmail` is the email to open when nothing is saved yet. Use one your app already has, or copy the [example email](/docs/document-format#example) from the document format page.

If the editor will be a page of its own, the [Shell](/docs/shell) gives you this page ready-made: the editor with a header, Export and Import buttons, messages and saving in the browser.

## With a bundler

```bash
npm install @mailquilt/editor
```

```html title="index.html"
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>Email editor</title>
  </head>
  <body>
    <mail-quilt></mail-quilt>
    <button id="export" type="button">Export HTML</button>
    <output id="exported-html"></output>
    <output id="load-error"></output>
    <script type="module" src="./main.js"></script>
  </body>
</html>
```

```js title="main.js"
import "@mailquilt/editor";
import { starterEmail } from "./starter-email.js";

const editor = document.querySelector("mail-quilt");
const saved = localStorage.getItem("email");

editor.addEventListener("mq-change", (event) => {
  localStorage.setItem("email", JSON.stringify(event.detail.document));
});
editor.addEventListener("mq-error", (event) => {
  document.querySelector("#load-error").textContent = event.detail.reason;
});
editor.value = saved ? JSON.parse(saved) : starterEmail;

document.querySelector("#export").addEventListener("click", async () => {
  document.querySelector("#exported-html").textContent =
    await editor.exportHtml();
});
```

Importing `@mailquilt/editor` registers the `<mail-quilt>` element. MJML, the library that turns an email into HTML, is large, so it only loads the first time someone opens Preview or you call `exportHtml()`. This example is tested with Vite.

## Without a bundler

For a page with no build step, such as in Rails, Laravel or plain HTML, load the CDN build in a `<script type="module">`:

```html title="index.html"
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>Email editor</title>
  </head>
  <body>
    <mail-quilt></mail-quilt>
    <button id="export" type="button">Export HTML</button>
    <output id="exported-html"></output>
    <output id="load-error"></output>
    <script type="module">
      import "https://cdn.jsdelivr.net/npm/@mailquilt/editor@0.1.0/dist/cdn/index.js";

      import { starterEmail } from "./starter-email.js";

      const editor = document.querySelector("mail-quilt");
      const saved = localStorage.getItem("email");

      editor.addEventListener("mq-change", (event) => {
        localStorage.setItem("email", JSON.stringify(event.detail.document));
      });
      editor.addEventListener("mq-error", (event) => {
        document.querySelector("#load-error").textContent = event.detail.reason;
      });
      editor.value = saved ? JSON.parse(saved) : starterEmail;

      document.querySelector("#export").addEventListener("click", async () => {
        document.querySelector("#exported-html").textContent =
          await editor.exportHtml();
      });
    </script>
  </body>
</html>
```

- **Use the full path to `dist/cdn/index.js`.** A shorter URL, such as `https://cdn.jsdelivr.net/npm/@mailquilt/editor`, loads a different build that won't run without a bundler.
- **Pin the version.** MJML loads from a file beside the build, and its name can change between releases. An unpinned URL could ask for a file the latest release no longer has.

## React

```bash
npm install @mailquilt/react
```

`@mailquilt/react` needs React 19. This example is a Client Component in a Next.js app:

```tsx title="app/editor.tsx"
"use client";

import { MailQuilt } from "@mailquilt/react";
import type { BlocksDocument, MailQuiltElement } from "@mailquilt/react";
import { useRef, useState } from "react";

import { starterEmail } from "./starter-email";

/** Nothing on the page shows the email until the browser has it, so this can't mismatch. */
const savedEmail = (): BlocksDocument | undefined => {
  if (typeof window === "undefined") {
    return;
  }
  const saved = localStorage.getItem("email");
  return saved ? JSON.parse(saved) : starterEmail;
};

export const Editor = () => {
  const editor = useRef<MailQuiltElement>(null);
  const [email, setEmail] = useState(savedEmail);
  const [html, setHtml] = useState("");
  const [loadError, setLoadError] = useState("");

  return (
    <main>
      <MailQuilt
        onChange={(next) => {
          localStorage.setItem("email", JSON.stringify(next));
          setEmail(next);
        }}
        onError={({ reason }) => setLoadError(reason)}
        ref={editor}
        value={email}
      />
      <button
        onClick={async () =>
          setHtml((await editor.current?.exportHtml()) ?? "")
        }
        type="button"
      >
        Export HTML
      </button>
      <output id="exported-html">{html}</output>
      <output id="load-error">{loadError}</output>
    </main>
  );
};
```

- `ref` gives you the `<mail-quilt>` element once the editor has loaded, so you can call `exportHtml()`.
- On the server, `MailQuilt` renders an empty `<mail-quilt>`, and the editor loads in the browser. You can render it from a Server Component too, though you'll need a Client Component like this one to pass it callbacks.

## The editor's API

`<mail-quilt>` has one property, two events and one method:

| Name | What it does |
| --- | --- |
| `value` | The email the editor shows. Set it to open an email, or to `undefined` to empty the editor. |
| `mq-change` | An event fired after every change. The new email is in `event.detail.document`. |
| `mq-error` | An event fired when the editor refuses an email, which then keeps the email it had. The reason is in `event.detail.reason`. The [document format](/docs/document-format) page lists what the editor accepts. |
| `exportHtml()` | Returns a promise of the email's HTML, ready to send. It rejects if the editor has no email. |

In React, these are the `value`, `onChange` and `onError` props, and `exportHtml()` on the `ref`.

Opening a different email clears undo history. Setting `value` to the email the editor last sent you changes nothing, even if you copied it or its keys come back in another order. That's why the examples can save each change and pass it straight back.

Two things to get right:

- **Only pass back the latest email.** An older one counts as a different email, so it opens and clears undo history. If you save each change to a server, don't set `value` from each response, or a slow response could replace what someone typed since.
- **Listen for `mq-error` before the editor can refuse anything.** It fires just after the code that sets `value` finishes, and it isn't fired again. A listener added straight after setting `value`, as in the examples, hears it. One added later, such as in a React `useEffect`, misses it, and people see an empty editor with no explanation. The React component's `onError` is always listening in time.

## Content Security Policy

Preview shows the email in a sandboxed iframe, which follows your page's Content Security Policy. Emails rely on inline styles, so Preview needs `'unsafe-inline'` in `style-src`:

```text
Content-Security-Policy: style-src 'self' 'unsafe-inline'
```

Without it, Preview shows the email without its styling. The editor and `exportHtml()` aren't affected, and the exported HTML is unchanged.

A page using the CDN build also needs `script-src` to allow the folder the build is in, because the build is several files. The path must end in `/`:

```text
Content-Security-Policy: script-src 'self' https://cdn.jsdelivr.net/npm/@mailquilt/editor@0.1.0/dist/cdn/
```

If it allows only `dist/cdn/index.js`, the editor doesn't load. An inline script like the one in the [CDN example](#without-a-bundler) also needs a nonce or hash, or you can move it into a file of your own.
