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. 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?
Section titled “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?
Section titled “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.
import { useState } from "react";
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 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?
Section titled “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.
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?
Section titled “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.
import { useEffect, useState } from "react";
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: { 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?
Section titled “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.
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.
How do I add an optional AI draft step?
Section titled “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.
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
Section titled “What’s next”- Send email with reusable templates for the Templates API request and response shapes
- Send email without SMTP to deliver the rendered template from a user’s account
- Create and send email drafts to save a rendered template as a draft for review