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.
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.
// 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 |
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
// 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
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
// 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.
// 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
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
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 for the current value.
draft and published are different things
This is the heart of the Templates design, and the mechanism that prevents accidents.
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.
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".
/** 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).
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.
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).
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.
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
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.
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.
// 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.
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.
Pre-production checklist
- All imports come from
react-email(no@react-email/components/@react-email/renderleft) - No
renderAsync; everyrendercall is awaited -
TailwindreceivespixelBasedPreset -
lang/dirset on bothHtmlandBody;Previewunder 90 characters - Images are png / gif / jpg with absolute production URLs; every
Imghas meaningfulalt - A
textpart 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_URLfamily /contact/this) - Variable values stay within 2,000 characters for strings and 2^53 - 1 for numbers
-
templateis never sent alongsidehtml/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 (Templates / Template Variables / Version History / Send Email) and the React Email documentation (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.