# Build an email template builder in React

Source: https://developer.nylas.com/docs/cookbook/email/react-template-builder/

A template builder lets your users write and save reusable email content without touching code. This recipe builds the front end for one in React: a form to author a template, a live preview that re-renders as you type, a control to insert `{{variable}}` placeholders, and a save action that persists the template through the Nylas Templates API.

The API mechanics, the Mustache placeholder syntax, the render call, and the response shape, are covered in [send email with reusable templates](/docs/cookbook/email/email-templates/). This page stays on the React layer and treats the API as the storage and rendering backend.

## What does an email template builder UI need?

A template builder needs 4 working parts: an editable form for the template name, subject, and body; a variable picker that inserts placeholders at the cursor; a preview pane that shows the rendered output; and a save action that writes to the backend. Each maps to a piece of React state.

The form holds 3 fields, so a single state object keeps them together. The variable picker writes into the field that currently has focus, which means you track the active field as a fourth piece of state. The preview reflects the same body the user is editing, debounced by roughly 300 milliseconds so you avoid a render request on every keystroke. The save action posts the form to the Templates API and surfaces the returned template ID. Keeping these 4 concerns separate makes the component easy to reason about: the form owns input, the picker mutates input, the preview is read-only, and save is the only path that talks to Nylas. Treat the API key as a server-side secret and proxy these calls through your own backend rather than exposing the key in the browser.

## How do I build the template form in React?

The form is a controlled component over a single state object with `name`, `subject`, and `body`. Track which field is focused so the variable picker knows where to insert. The snippet below sets up the 3 fields and an `activeField` value, all driven by one `useState` call.

```tsx
type TemplateDraft = {
  name: string;
  subject: string;
  body: string;
};

export function TemplateForm() {
  const [draft, setDraft] = useState<TemplateDraft>({
    name: "",
    subject: "",
    body: "",
  });
  const [activeField, setActiveField] = useState<keyof TemplateDraft>("body");

  const updateField = (field: keyof TemplateDraft, value: string) =>
    setDraft((current) => ({ ...current, [field]: value }));

  return (
    <form className="template-form">
      <input
        value={draft.name}
        placeholder="Booking confirmed message"
        onFocus={() => setActiveField("name")}
        onChange={(event) => updateField("name", event.target.value)}
      />
      <input
        value={draft.subject}
        placeholder="{{user.name}}, your booking is confirmed!"
        onFocus={() => setActiveField("subject")}
        onChange={(event) => updateField("subject", event.target.value)}
      />
      <textarea
        value={draft.body}
        rows={12}
        placeholder="<p>Hello {{user.name}}, your booking has been confirmed.</p>"
        onFocus={() => setActiveField("body")}
        onChange={(event) => updateField("body", event.target.value)}
      />
    </form>
  );
}
```

One `updateField` helper covers all 3 inputs, so the form scales to more fields with zero new handlers. Note that the body is HTML, the same format the [Templates API](/docs/cookbook/email/email-templates/) stores, so a `<textarea>` is the simplest editor to start with before you reach for a rich-text library.

## How do I add a variable insertion control?

A variable picker is a small list of known placeholders that inserts a token like `{{user.name}}` into whichever field is focused. The example below appends the chosen variable to the active field. Define the variables once and render them as buttons.

```tsx
const VARIABLES = ["user.name", "user.surname", "user.email", "booking.date"];

function VariablePicker({
  onInsert,
}: {
  onInsert: (token: string) => void;
}) {
  return (
    <div className="variable-picker">
      {VARIABLES.map((name) => (
        <button
          key={name}
          type="button"
          onClick={() => onInsert(`{{${name}}}`)}
        >
          {name}
        </button>
      ))}
    </div>
  );
}

// In TemplateForm, wire onInsert to the focused field:
const insertVariable = (token: string) =>
  updateField(activeField, draft[activeField] + token);
```

For a first version, appending to the end of the active field keeps the code short and predictable. If you want true cursor-position insertion, hold a `ref` to the focused element and splice the token at its `selectionStart`. Wrapping each name in double braces matches the Mustache syntax Nylas expects, so the picker output drops straight into any of the 3 fields. With the picker wired, your users get one-click personalization without memorizing the placeholder format.

## How do I render a live preview pane?

The preview pane shows the template with sample values substituted, which you get by sending the body to the render endpoint. Debounce the call so you render at most a few times per second instead of on every keystroke. The hook below re-renders the preview about 300 milliseconds after the user stops typing.

```tsx
function usePreview(subject: string, body: string) {
  const [preview, setPreview] = useState({ subject: "", body: "" });

  useEffect(() => {
    let cancelled = false;
    const timer = setTimeout(async () => {
      const response = await fetch("/api/templates/preview", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          subject,
          body,
          variables: {
            user: { name: "Leyah", surname: "Miller", email: "leyah@example.com" },
            booking: { date: "March 3" },
          },
        }),
      });
      const data = await response.json();
      if (!cancelled) setPreview(data); // ignore a slow earlier response
    }, 300);

    return () => {
      cancelled = true;
      clearTimeout(timer);
    };
  }, [subject, body]);

  return preview;
}
```

The `/api/templates/preview` route on your backend picks one of two render endpoints. For unsaved copy in the editor it calls `POST /v3/templates/render` with the `body`, an `engine` of `mustache`, and the sample `variables`; that ad hoc endpoint returns only `data.body`, so render the subject from local form state. Once the template is saved, it can call `POST /v3/templates/{template_id}/render`, which takes only `variables` and returns both `data.subject` and `data.body`. Either way the proxy returns the `data` object and the hook reads the rendered fields it contains. Render the returned HTML body inside a sandboxed `iframe` rather than `dangerouslySetInnerHTML` so untrusted template markup cannot run script in your app. The cleanup function clears the pending timer, so a fast typist never stacks up render calls.

## How do I save the template through the Nylas API?

Saving posts the form to a backend route that calls `POST /v3/templates` with the `name`, `subject`, `body`, and an `engine` of `mustache`. The browser never holds the API key. The handler below sends the draft and stores the returned template ID so later edits target the same record.

```tsx
async function saveTemplate(draft: TemplateDraft) {
  const response = await fetch("/api/templates", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ ...draft, engine: "mustache" }),
  });

  if (!response.ok) {
    throw new Error("Failed to save template");
  }

  const { data } = await response.json();
  return data.id;
}
```

Your `/api/templates` route attaches the `Authorization: Bearer` header server-side and forwards the body to Nylas. A create call returns the stored template with its ID in under 1 second in typical use; keep that ID in state so a second save switches to `PUT /v3/templates/{template_id}` and updates in place instead of creating a duplicate. Once a template is saved, you can render it with per-recipient data and hand the result to the send flow described in [send email without SMTP](/docs/cookbook/use-cases/build/send-email-without-smtp/).

## How do I add an optional AI draft step?

An AI draft step lets users describe the email in 1 sentence and get a starter body to refine. Call your LLM provider from the backend, then route the generated HTML into the form's body field. The handler below sends a prompt and returns a draft the user can edit before saving.

```tsx
async function generateDraft(prompt: string) {
  const response = await fetch("/api/templates/ai-draft", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ prompt }),
  });
  const { body } = await response.json();
  return body; // HTML string with {{variables}} the model was told to include
}

// On the backend (OpenAI-style), instruct the model to emit HTML with placeholders:
// const completion = await openai.chat.completions.create({
//   model: "gpt-4o-mini",
//   messages: [
//     { role: "system", content: "Return an HTML email body. Use {{user.name}} for personalization." },
//     { role: "user", content: prompt },
//   ],
// });
// return completion.choices[0].message.content;
```

Keep the generation step strictly advisory: the model output lands in the editable body field, and the user reviews it before the save call writes anything. Tell the model to include the same `{{variable}}` tokens your picker offers, so an AI draft stays compatible with the render step. Treat the prompt as untrusted input and never forward the API key or user data the feature does not need. A single sentence prompt typically returns a draft of 3 paragraphs in under 5 seconds, which cuts the blank-page problem most template authors hit.

## What's next

- [Send email with reusable templates](/docs/cookbook/email/email-templates/) for the Templates API request and response shapes
- [Send email without SMTP](/docs/cookbook/use-cases/build/send-email-without-smtp/) to deliver the rendered template from a user's account
- [Create and send email drafts](/docs/cookbook/email/manage-drafts/) to save a rendered template as a draft for review