# Designing Resend Email Templates: React Email vs. the Templates API in Production

> Should your email body live in code (React Email) or in Resend Templates? Triple-brace variables, the draft/publish split, the current render signature, and Tailwind's limits — faithful to the official docs and to the resend@6.4.1 type definitions.

- Published: 2026-08-06
- Author: 友田 陽大
- Tags: Resend, React Email, メール配信, TypeScript, Next.js, 到達率, テスト
- URL: https://tomodahinata.com/en/blog/resend-templates-variables-react-email-design-guide
- Category: Resend & transactional email
- Pillar guide: https://tomodahinata.com/en/blog/resend-transactional-email-production-guide

## Key points

- There are only three places an email body can live: an inline HTML string, React Email, or Resend Templates. The choice is decided by who edits the copy and whether a fix requires a deploy — not by technical taste
- React Email 6.0 (2026-04-16) collapsed everything into a single react-email package. Imports from @react-email/components and renderAsync are history; the current API is imports from react-email plus an async render
- Resend Templates separate draft from published. update only writes the draft; nothing reaches your sending until you publish. At send time, template is mutually exclusive with html/react/text at the type level
- Variables use triple braces. Keys are ASCII letters, digits and underscores only, max 50 characters; string values max 2,000 characters. FIRST_NAME, LAST_NAME, EMAIL and the UNSUBSCRIBE_URL family are reserved
- Email HTML does not follow web rules. Gmail clips a message over 102 KB, Tailwind's rem breaks in some clients so pixelBasedPreset is mandatory, and a plain-text part is insurance for both deliverability and accessibility

---

Email HTML is a different language from the HTML you write for the web in 2026. Flexbox and Grid work in some clients and not in others, and even `rem` is not safe. Worse, **an email body is never written once and forgotten**. Prices change, wording changes, legal asks for one more sentence, and then you need an English version.

What this article solves is a design question: **where should that change land?** Do you keep the body as TypeScript in your repository, or put it in the Resend dashboard and hand it to non-engineers? Get it wrong and you end up in one of two failure modes: a production deploy is required to fix a single sentence, or nobody can tell who changed what and when.

I run this portfolio site itself on Resend in production — the contact form, lead-magnet delivery, a seven-part email course, and the delivery email after a Stripe payment. Its current implementation is the "inline HTML string" option described below, so I have lived with both its benefits and its ceiling. What follows is faithful to the **official Resend and React Email documentation as of August 2026, plus the type definitions of the installed `resend@6.4.1`**, with my operational judgement layered on top. The full map is in the [Resend production guide](/en/blog/resend-transactional-email-production-guide).

---

## There are only three places an email body can live

The options look endless, but operationally there are three. Both decision axes come down to who edits the copy and whether editing requires a deploy.

| Dimension | Inline HTML string | React Email (code) | Resend Templates (dashboard) |
|---|---|---|---|
| Who edits | Developers only | Developers only | Developers + non-engineers |
| Diff review | Possible in Git (hard to read) | Possible in Git (reads as JSX) | Version history (never passes code review) |
| Type safety | None (string concatenation) | Yes (via props types) | Partial (variable key and type only) |
| i18n | Hand-rolled locale branching | Props or per-locale components | Duplicate the template per language |
| Fixable without a deploy | No | No | Yes (just publish) |
| Testability | String comparison only | Snapshot the render output | No official programmatic method |
| Setup cost | Zero | Dependency + preview environment | Requires a Full access API key |

I chose the first option on this site for one reason: **I am the only person who touches the copy.** `lib/email-course-render.ts` splits the body into paragraphs, converts them to `<p>` elements with inline styles, injects the footer required by Japan's anti-spam email law, and **always returns HTML and plain text as a pair** from a pure function.

```ts
// lib/email-course-render.ts (running in production, excerpt)
export function renderDay(day: EmailCourseDay, unsubUrl: string): { html: string; text: string } {
  const footer = buildFooter(unsubUrl);
  // The text version only swaps the footer placeholder. Leaving it to auto-generation
  // from the HTML gives no guarantee about where the unsubscribe URL lands (= reading order).
  const text = day.bodyMarkdown.replace(EMAIL_COURSE_FOOTER_PLACEHOLDER, footer.text);
  // ...convert paragraphs into <p style="..."> and join...
  return { html, text };
}
```

The ceiling is worth stating honestly: **fixing one sentence requires a deploy, and design intent is buried in style strings.** The moment more than one person edits the copy, this approach breaks.

---

## React Email: keeping the body in code

### Update your assumptions to 2026 first

React Email 6.0 (2026-04-16) **restructured the packages from the ground up**. If your mental model is older than that, copying an official sample will not work.

| Outdated understanding (discard) | Correct in 2026 (official) |
|---|---|
| Import components from `@react-email/components` | Everything imports from `react-email`; individual packages are folded in |
| Import render from `@react-email/render` | `render` is also exported from `react-email` |
| Use `renderAsync` | Removed in 5.0; unified into `render` (which is itself async) |
| `@react-email/preview-server` | Renamed to `@react-email/ui` |
| Tailwind means v3 | The Tailwind component runs tailwindcss 4.1.12 |
| `className` inlines styles on components too | Styles are inlined **on elements only** |

```bash
npm uninstall @react-email/components @react-email/preview-server
npm install react-email@latest @react-email/ui@latest
```

Note that the latest `react-email` on npm is 6.9.1 (published 2026-07-23), but **the official changelog stops at 6.0.0**. What changed between 6.1 and 6.9 cannot be confirmed from public sources, so this article makes no claims about behaviour specific to those versions.

### A minimal template

```tsx
// emails/order-confirmation.tsx
import { Body, Button, Container, Head, Heading, Html, Img, Preview,
         Section, Tailwind, Text, pixelBasedPreset } from "react-email";
import * as React from "react";

interface Props { customerName?: string; productName?: string; receiptUrl?: string }

export default function OrderConfirmation({
  // Defaults serve double duty: readable previews, and no blank slots when a prop is missing
  customerName = "there", productName = "your order", receiptUrl = "https://example.com/receipt",
}: Props) {
  return (
    // Put lang / dir on BOTH Html and Body.
    // Some clients strip the html or body tag entirely, so one of them alone may not survive.
    <Html lang="en" dir="ltr">
      <Head />
      {/* Without pixelBasedPreset, Tailwind emits rem, which breaks in clients that lack rem support */}
      <Tailwind config={{ presets: [pixelBasedPreset] }}>
        {/* Preview text: the docs recommend keeping it under 90 characters */}
        <Preview>We received your order for {productName}</Preview>
        <Body lang="en" dir="ltr" className="bg-white font-sans">
          <Container className="mx-auto py-12">
            {/* svg support is weak, so use png / gif / jpg — and an absolute production URL */}
            <Img src="https://cdn.example.com/logo.png" alt="Acme" width="120" />
            <Heading className="text-2xl font-semibold">Thanks, {customerName}</Heading>
            <Text className="text-base text-zinc-700">Your payment for {productName} went through.</Text>
            <Section className="mt-6">
              {/* Button renders as an a element. Never ship a CTA that is only an image. */}
              <Button className="rounded-md bg-black px-5 py-3 text-white" href={receiptUrl}>
                View receipt
              </Button>
            </Section>
          </Container>
        </Body>
      </Tailwind>
    </Html>
  );
}
```

A few component constraints are worth memorising. `Html` and `Body` both take `lang` (default `en`) and `dir` (default `ltr`) — set them on both. `Preview` should stay under 90 characters. `Img` renders `.png`, `.gif` and `.jpg` everywhere, but **`.svg` is poorly supported no matter how it is referenced, so avoid it**. `Button` and `Link` require `href` and default `target` to `_blank`. `Font` goes inside `<Head>`, and `fallbackFontFamily` matters because not all clients support web fonts. `Heading` is the only component with the margin shorthands `m`, `mx`, `my`, `mt`, `mr`, `mb`, `ml` in addition to `as` (`h1` through `h6`). The docs state that every component is tested on **Gmail, Apple Mail, Outlook, Yahoo! Mail, HEY and Superhuman**.

### render is async; pretty and toPlainText are separate functions

```tsx
import { render, pretty, toPlainText } from "react-email";

// render returns Promise<string>. Forget the await and your body becomes "[object Promise]".
const html = await render(<OrderConfirmation customerName="Hinata" productName="Aegis" />);
const readable = await pretty(html); // wraps prettier's format(), so it returns a Promise
const text = toPlainText(html);      // this one is synchronous, and skips img by default
```

`render` takes three options: `pretty` (beautify), `plainText` (return text instead) and `htmlToTextOptions`. Internally it replaces the leading DOCTYPE with XHTML 1.0 Transitional and strips image preload links — except when `plainText: true`, where no DOCTYPE is prepended. The docs flag one browser-only caveat: running `render` in the browser needs the `web-streams-polyfill` package for Safari and iOS.

### Two ways to send, and why I prefer the second

```tsx
// Option A: let the SDK render for you (the official form)
await resend.emails.send({ from, to, subject, react: <OrderConfirmation productName="Aegis" /> });
```

The docs frame it as: when integrating with other services you must convert the React template to HTML yourself, but **Resend takes care of that for you**. In production, though, I recommend the following instead.

```tsx
// Option B: render yourself and pass both html and text
// (my operational judgement, not an official recommendation)
import { render, toPlainText } from "react-email";

const html = await render(<OrderConfirmation customerName="Hinata" productName="Aegis" />);

const { data, error } = await resend.emails.send({
  from: "Acme <onboarding@resend.dev>",
  to: ["delivered@resend.dev"],
  subject: "Thanks for your order",
  html,
  // Omitting text hands the plain-text part over to auto-generation.
  // Resend's own deliverability checks list "Include Plain Text Version".
  text: toPlainText(html),
});

// The SDK does not throw except at the network layer. Always branch on error.
if (error) console.error("[email] phase=send_error", { name: error.name });
```

Three reasons. You **own the plain-text version**; rendering becomes a pure function you can verify separately from sending; and — most practically — you **decouple the SDK from your React Email version**.

That last point is not speculation. It is visible in the installed `resend@6.4.1`. When you pass `react:`, that version dynamically imports `@react-email/render` (declared in `peerDependencies` as `@react-email/render: "*"`, marked optional through `peerDependenciesMeta`), and `templates.create({ react })` reaches for **`renderAsync`**. But `renderAsync` was deleted in React Email 5.0, and the `@react-email/render` package itself was folded into `react-email` in 6.0. On that combination, rendering `react:` **throws an exception** with "Failed to render React component" — one of the very few paths that does not surface through `{ data, error }`. The latest `resend` on npm is 6.18.1 (published 2026-07-28); whether that wiring has been updated is unverified. **Option B, where you call `render()` yourself and pass `html`, sidesteps the coupling entirely.**

### Preview with `email dev`

```bash
npx email dev                          # watches ./emails by default, opens http://localhost:3000
npx email dev --dir src/emails --port 3001
```

The toolbar carries **Linter** (checks your content and links), **Compatibility** (HTML/CSS support backed by caniemail) and **Spam** (how spam checkers see the email), plus a **Resend tab** with Upload and Bulk Upload to push templates into Resend.

Two traps. The first is **static files**: images in `emails/static` are served at `http://localhost:3000/static/...`, but as the docs state plainly, "this does not mean your images are hosted for you to send the emails" — send that and the image will not load in the inbox. The docs show a `baseURL` pattern that prefixes a CDN origin only when `process.env.NODE_ENV === "production"`. The second is **file detection**: the preview server treats a file as an email if its extension is `.js`, `.jsx` or `.tsx` and it contains an `export default`. To hide shared components from the list, prefix the directory with an underscore, as in `_components`. Preview-only props go on `Email.PreviewProps`.

---

## Resend Templates: keeping the body in the dashboard

Templates are stored on Resend, and at send time you **transmit only an id and the variables**. In the docs' own words: send only the template `id` and `variables` instead of the HTML, and Resend renders the final email and sends it. The recommended use cases are login/auth, onboarding, ecommerce, notifications and Automations.

### Variables use triple braces

```ts
import { Resend } from "resend";

const resend = new Resend(process.env.RESEND_API_KEY);

// create returns a thenable that can chain publish (create and publish in one step)
const { data, error } = await resend.templates
  .create({
    name: "order-confirmation",
    alias: "order-confirmation",             // usable in place of the id when sending
    from: "Acme Store <store@example.com>",  // a default that the send payload can override
    subject: "Thanks for your order!",       // same
    html: "<p>Product: {{{PRODUCT}}}</p><p>Total: {{{PRICE}}}</p>", // triple braces, not double
    // Omit text and it is generated from the HTML. An empty string opts out of that generation.
    text: "Product: {{{PRODUCT}}}\nTotal: {{{PRICE}}}",
    variables: [
      // The Node SDK uses camelCase fallbackValue; the REST wire format uses fallback_value
      { key: "PRODUCT", type: "string", fallbackValue: "item" },
      { key: "PRICE", type: "number", fallbackValue: 0 },
    ],
  })
  .publish();

// Template CRUD needs a Full access API key.
// A send-only key is rejected with restricted_api_key (401).
if (error) console.error("[template] phase=create_error", { name: error.name });
```

Here is the variable contract exactly as documented.

| Item | Specification |
|---|---|
| Syntax | Triple braces in the stored HTML; typing two braces in the editor opens the variable palette |
| Definition fields | `key` (conventionally uppercase), `type` (`string` or `number`), fallback value |
| No fallback | If you do not pass a value at send time, **the email is not sent and a validation error is returned** |
| Key rules at send time | ASCII letters, digits and underscores only. **Max 50 characters** |
| Value rules at send time | Strings up to **2,000 characters**; numbers no greater than **2^53 - 1** |
| Reserved names | `FIRST_NAME` / `LAST_NAME` / `EMAIL` / `UNSUBSCRIBE_URL` (some pages write `RESEND_UNSUBSCRIBE_URL`) / `contact` / `this` |
| Variable count limit | **The API reference says 50; only the introduction page says 20** — the docs disagree |

Those last two rows are places where the official documentation contradicts itself as of 2026-08-06. The safe move is to **avoid both spellings** of the reserved unsubscribe name, and to **never design past 20 variables**. If you need more than twenty, that body belongs in code, not in a template. Check the [official page](https://resend.com/docs/dashboard/templates/template-variables) for the current value.

### draft and published are different things

This is the heart of the Templates design, and the mechanism that prevents accidents.

```text
create ──▶ [draft] ──publish──▶ [published]  ← only this version is used for sending
                                     │
                    update ──▶ [new draft] ─┤  update never rewrites published
                                     │
                              publish ──────▶ [new published]

Reverting from version history creates "a new draft whose content is the selected
version". The published version does not change at that moment.
```

In plain terms: a template is **created as a draft** and **cannot be used to send until it is published**. Edits after publishing are also **saved as a draft** and do not affect live sending until you publish again. Version history shows a preview of each version, who made it and when, and lets you revert — but **reverting only creates a new draft and leaves published untouched**. The docs are explicit that this separation lets you "add or remove variables, update the design, and more without affecting your existing emails or raising validation errors".

So **`update` means "write to the draft" and `publish` means "release to production"**. Make that distinction consciously in code review.

```ts
await resend.templates.update("order-confirmation", { html: "<p>Total: {{{PRICE}}}</p>" }); // draft
await resend.templates.publish("order-confirmation");                                       // live
```

Every path parameter is documented as **"the ID or alias"**, on every endpoint. Using an alias like `order-confirmation` instead of scattering UUIDs through your code makes reviews readable.

### Catching unpublished changes in CI (my judgement, not official)

The response of `GET /templates/{id}` carries `status`, `published_at` and `current_version_id`, plus **`has_unpublished_versions` (boolean)**. It is the only signal that mechanically detects "a draft nobody remembered to publish is sitting in production".

```ts
/** A CI guard that fails on forgotten publishes. List rows do not include
 *  has_unpublished_versions (per the SDK types), so you must get() each id. */
export async function findUnpublishedTemplates(resend: Resend): Promise<string[]> {
  const { data: list, error } = await resend.templates.list({ limit: 100 });
  if (error || !list) throw new Error(`templates.list failed: ${error?.name ?? "unknown"}`);

  const stale: string[] = [];
  for (const item of list.data) {
    const { data: tpl } = await resend.templates.get(item.id);
    if (tpl?.has_unpublished_versions) stale.push(tpl.alias ?? tpl.id);
  }
  return stale;
}
```

This is not a documented workflow — it is assembled from published response fields. Because it calls `get` once per template, a large template set will run into rate limits (covered in the [idempotency and retry guide](/en/blog/resend-idempotency-retry-error-handling-reliability-guide)).

### At send time, template excludes html / react / text

Here the `resend@6.4.1` type definitions are the specification. `CreateEmailOptions` is a **discriminated union** of "a body (at least one of `react` / `html` / `text`)" or "a `template`", so passing both **fails to compile**. The API returns a validation error too.

```ts
const { data, error } = await resend.emails.send({
  to: ["delivered@resend.dev"],
  // from / subject can be omitted if the template supplies defaults (they become optional in the type)
  template: {
    id: "order-confirmation", // a UUID or an alias — but only a published template
    variables: { PRODUCT: "Vintage Macintosh", PRICE: 499 }, // values are string | number
  },
  // html: "<p>...</p>",  ← adding this is a type error; the API also returns a validation error
});

// Classify by name and treat the status code as supporting information
// (the docs and the SDK constants disagree on the status of some entries).
if (error) console.error("[email] phase=template_send_error", { name: error.name });
```

Precedence is equally clear: **`from`, `subject` and `reply_to` in the payload take precedence over the template's defaults**, and if the template has no default you must supply them in the payload. That is why some official samples omit `subject` entirely — the template carries a default one. Templates are supported on **both** `/emails` and `/emails/batch` (bulk sending is covered in the [batch, scheduling and subscription guide](/en/blog/resend-batch-scheduled-broadcasts-audiences-topics-unsubscribe-guide)).

### Moving a React Email template into Templates

There is a middle path where developers own the skeleton and non-engineers own day-to-day edits.

```bash
npx react-email@latest resend setup   # enables the Resend tab in the preview server (Full Access key)
resend templates create --name "Welcome" --subject "Welcome to Acme" --react-email ./emails/welcome.tsx
resend templates publish <id>          # create alone leaves it as a draft
```

You can also paste code into the dashboard editor, but the constraint is documented: "When pasting React Email code, only imports from `@react-email/components` and `react` are supported. Local file imports (e.g., `./components/Logo`) and other third-party packages are not supported in the editor." That the sentence still names the old package is an inconsistency inside the docs, but the constraint itself is real: **a `.tsx` that imports shared components cannot simply be pasted in.**

---

## Choosing between them

```text
Who edits the copy?
├─ Developers only
│   ├─ The body carries logic (conditionals, loops, calculations)
│   │   └─▶ React Email. Cover it with snapshot tests over render
│   └─ The body is a few lines of fixed text
│       └─▶ An inline HTML string is enough. Make it a pure function returning html + text
│
└─ Non-engineers edit it too
    ├─ You need history and the ability to roll back → Resend Templates (draft / publish / history)
    ├─ Developers must own the design skeleton
    │   └─▶ Build in React Email, upload via the CLI or the preview server's Resend tab
    └─ You expect more than 20 variables
        └─▶ That is not a template. Bring it back into code

Multiple languages (ja / en)?
├─ Templates → one template per language (suffix the alias with -ja / -en)
└─ React Email → take the locale as a prop, or split into per-locale components
```

When in doubt, ask: **"is there any chance someone other than me will edit this copy next month?"** If yes, it is worth moving toward Templates.

---

## The reality of email HTML

**Size**: Gmail clips a message over **102 KB** and hides the remainder behind a "view entire message" link. Resend's Deliverability Insights shows both the threshold and your current size. Note that 102 KB is **Gmail's clipping threshold, not a Resend API limit** — no maximum body size for `html` / `text` is documented as an API constraint.

**Units**: Tailwind uses `rem` by default, and the docs state plainly that **some email clients do not support it**, so `pixelBasedPreset` converts styles to a 16px basis. It is not optional in practice.

**Stripped tags**: the reason to put `lang` and `dir` on both `Html` and `Body` is that **some email clients strip the html or body tag**. One of them alone may not survive.

**Images**: avoid `.svg`. And as above, **the preview server's `static` directory is not production hosting** — an image that looked fine locally disappearing in the inbox is the classic symptom of that misunderstanding.

**Plain text**: the "Include Plain Text Version" insight tells you to pass it through the `text` parameter and notes it can be generated by React Email's render-to-plain-text utility. Omit `text` and it is generated from the HTML — but **passing an empty string disables that generation entirely**, leaving an email with no text part at all. Do that unintentionally and deliverability suffers.

**CTAs**: React Email's `Button` renders as an `<a>` with Outlook MSO conditional comments inserted automatically. Verify in the generated HTML that **the clickable region works as a text link**.

On **dark mode**, I could not find design guidance in either the Resend or the React Email documentation (as of 2026-08-06). The preview server's Compatibility panel is backed by caniemail data, so that is the reliable place to check individual properties. **Confirm "readable in dark mode" on a real client, not from memory.** Deliverability itself (SPF / DKIM / DMARC) is covered in the [domain authentication guide](/en/blog/resend-domain-authentication-spf-dkim-dmarc-deliverability-guide).

---

## Accessibility: do not cut corners in email either

The official docs have no accessibility chapter, so I will separate **what is backed by documented behaviour** from **my own judgement**.

Documented. `Img` takes an `alt` prop, so unless an image is purely decorative, write alt text that describes it. Put `lang` on both `Html` and `Body` — the same fix for stripped tags also guarantees language detection. `toPlainText` ships default selectors that **skip `img`** and **avoid repeating a URL for an `a` whose text already equals its href**. To keep an element in the HTML but out of the text version, add **`data-skip-in-text="true"`** (it is not excluded from the HTML output).

My judgement from here. Hold **contrast** to the same standard you use on the web; because email clients may override background colours, specifying a text colour without a background colour can become unreadable when inverted. Write **link text** that stands alone — "View receipt", not "click here" — because the plain-text version merely appends the URL, so vague wording loses its context completely. And remember that **the plain-text part is your screen-reader insurance**. If you are not confident that the HTML part survives linear reading order, raising the quality of the text part is the higher-return investment.

---

## Testing: what to verify, and where

| Layer | Method | What it verifies |
|---|---|---|
| Rendering | The `email dev` preview | Appearance plus the Linter / Compatibility / Spam panels |
| Rendering | Snapshots over `render` | That a props change did not silently break the HTML |
| Sending | Playwright (official guide) | The path from route handler to send |
| Sending | Test addresses | Delivered, bounced, complained and suppressed events |
| Templates | Test emails in the dashboard | The rendered result with real variable values, in your own inbox |

Snapshots are the biggest practical payoff of choosing React Email.

```tsx
// tests/emails/order-confirmation.test.tsx (it contains JSX, so the extension is .tsx)
import { expect, it } from "vitest";
import { render, toPlainText } from "react-email";
import OrderConfirmation from "@/emails/order-confirmation";

it("interpolates props and emits the CTA as a real link", async () => {
  const html = await render(
    <OrderConfirmation customerName="Hinata" productName="Aegis" receiptUrl="https://example.com/r/1" />,
  );
  // Pinning an exact match makes every layout tweak fail the test.
  // The only failure worth having is "the interpolation or the link disappeared".
  expect(html).toContain("Hinata");
  expect(html).toContain('href="https://example.com/r/1"');
  expect(toPlainText(html)).toContain("View receipt"); // is the reading-order insurance intact?
});
```

For Playwright, Resend publishes an official guide with two explicit strategies: calling the real API tests the entire flow "but counts towards your account's sending quota", while mocking the response lets you test your app's flow "without calling the Resend API". Either way, the official caution is to **use a test email address so your tests do not affect deliverability**.

When a test really does send, use the four test addresses — `delivered@`, `bounced@`, `complained@` and `suppressed@resend.dev`. The finer print (which of them support plus-labelling, the sending quota they consume, and the addresses Resend rejects outright) is collected in the [idempotency and retry guide](/en/blog/resend-idempotency-retry-error-handling-reliability-guide).

One honest limitation to close on. **There is no official programmatic way to test a Template.** There is no API to assert "the variables interpolated correctly", and no template-specific error code — you get the generic `validation_error`, `missing_required_field` or `invalid_parameter`. The preview server's Linter, Compatibility and Spam panels are UI only. Adopt Templates understanding that **choosing them means returning part of your automated testing to human review**. The route-handler side is covered in the [App Router implementation guide](/en/blog/resend-nextjs-app-router-route-handler-react-email-guide).

---

## Pre-production checklist

- [ ] All imports come from `react-email` (no `@react-email/components` / `@react-email/render` left)
- [ ] No `renderAsync`; every `render` call is **awaited**
- [ ] `Tailwind` receives `pixelBasedPreset`
- [ ] `lang` / `dir` set on **both** `Html` and `Body`; `Preview` under 90 characters
- [ ] Images are png / gif / jpg with **absolute production URLs**; every `Img` has meaningful `alt`
- [ ] A `text` part is always sent, and is not disabled with an empty string
- [ ] The CTA works as an `<a>`, and its link text makes sense on its own
- [ ] If using Templates, they are **published**, and CI watches `has_unpublished_versions`
- [ ] No reserved variable names (`FIRST_NAME` / `LAST_NAME` / `EMAIL` / `UNSUBSCRIBE_URL` family / `contact` / `this`)
- [ ] Variable values stay within 2,000 characters for strings and 2^53 - 1 for numbers
- [ ] `template` is never sent alongside `html` / `react` / `text`
- [ ] The API key used for template CRUD has **Full access**
- [ ] Body size sits well under 102 KB
- [ ] Send tests used `delivered@` / `bounced@` and the other test addresses

---

## Conclusion: where the body lives decides what changes cost

What you are really choosing in email template design is not a way to write HTML — it is **who has to move when the copy changes**.

- **React Email** keeps a developer's tools — types, diffs, snapshot tests — fully in play, at the cost of a deploy for every wording fix.
- **Resend Templates** let you **safely rewrite a template that is live in production**, thanks to the draft/published split, at the cost of returning part of your automated testing to human review.
- **A hybrid** is legitimate: build the skeleton in React Email, upload it through the CLI or the preview server's Resend tab, and hand day-to-day editing to non-engineers.

Whichever path you take, three things are the shared floor: **always ship a plain-text part**, **survive images not loading**, and **make the CTA a working link**. All three pay off in deliverability and accessibility at once. Start by reading one of your product's outgoing emails again — in both its generated HTML and its plain-text form.

> This article is based on the [Resend documentation](https://resend.com/docs) (Templates / Template Variables / Version History / Send Email) and the [React Email documentation](https://react.email/docs) (render / components / Tailwind / CLI), both as of August 2026, plus the type definitions of the installed `resend@6.4.1`, restructured around production decision criteria. Specifications and limits change, so verify current values on the official pages before adopting them. Where the official documentation contradicts itself (the variable count limit and the reserved-name spelling), that is stated explicitly.
