Skip to main content
友田 陽大
Resend & transactional email
Resend
React Email
メール配信
TypeScript
Next.js
到達率
テスト

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
Reading time
21 min read
Author
友田 陽大
Share

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.

DimensionInline HTML stringReact Email (code)Resend Templates (dashboard)
Who editsDevelopers onlyDevelopers onlyDevelopers + non-engineers
Diff reviewPossible in Git (hard to read)Possible in Git (reads as JSX)Version history (never passes code review)
Type safetyNone (string concatenation)Yes (via props types)Partial (variable key and type only)
i18nHand-rolled locale branchingProps or per-locale componentsDuplicate the template per language
Fixable without a deployNoNoYes (just publish)
TestabilityString comparison onlySnapshot the render outputNo official programmatic method
Setup costZeroDependency + preview environmentRequires 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/componentsEverything imports from react-email; individual packages are folded in
Import render from @react-email/renderrender is also exported from react-email
Use renderAsyncRemoved in 5.0; unified into render (which is itself async)
@react-email/preview-serverRenamed to @react-email/ui
Tailwind means v3The Tailwind component runs tailwindcss 4.1.12
className inlines styles on components tooStyles 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.

ItemSpecification
SyntaxTriple braces in the stored HTML; typing two braces in the editor opens the variable palette
Definition fieldskey (conventionally uppercase), type (string or number), fallback value
No fallbackIf you do not pass a value at send time, the email is not sent and a validation error is returned
Key rules at send timeASCII letters, digits and underscores only. Max 50 characters
Value rules at send timeStrings up to 2,000 characters; numbers no greater than 2^53 - 1
Reserved namesFIRST_NAME / LAST_NAME / EMAIL / UNSUBSCRIBE_URL (some pages write RESEND_UNSUBSCRIBE_URL) / contact / this
Variable count limitThe 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

LayerMethodWhat it verifies
RenderingThe email dev previewAppearance plus the Linter / Compatibility / Spam panels
RenderingSnapshots over renderThat a props change did not silently break the HTML
SendingPlaywright (official guide)The path from route handler to send
SendingTest addressesDelivered, bounced, complained and suppressed events
TemplatesTest emails in the dashboardThe 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/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 (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.

Frequently asked questions

Should I choose React Email or Resend Templates?
If only developers touch the copy, keep it in code with React Email. If non-engineers rewrite the copy, use Resend Templates. React Email gives you Git diffs, types and tests, but a wording fix needs a deploy. Templates can be edited in the dashboard with no deploy and carry version history, but the change never passes through code review. A hybrid also works: build the skeleton in React Email, upload it as a Template, and hand day-to-day editing to non-engineers.
How do I write variables in a Resend Template?
Inside the stored HTML they use triple braces. In the visual editor, typing two braces opens the variable palette. Each variable is defined on the template with a key, a type (string or number) and an optional fallback value, and you pass the actual values at send time through template.variables. If a variable has no fallback and you do not pass a value, the email is not sent and a validation error is returned.
I edited my template but the sent email did not change. Why?
Because you did not publish it. Templates are created in a draft state and must be published before they can be used to send. Edits made after publishing are also saved as a draft; nothing reaches live sending until you publish again. This is deliberate — it lets you rewrite a template that is running in production without breaking anything. Through the API, resend.templates.update() writes the draft and resend.templates.publish() promotes it.
Is React Email's render synchronous or asynchronous?
Asynchronous. The current render(node, options?) returns Promise<string>, so always await it. renderAsync was removed in React Email 5.0 and folded into render. The pretty helper returns a Promise; toPlainText is synchronous. The form the docs currently recommend is await pretty(await render(...)).
Do I really need a plain-text part?
Yes. Resend's Deliverability Insights lists 'Include Plain Text Version' as a deliverability check, and it is passed through the text parameter. If you omit it, a plain-text version is generated from the HTML (passing an empty string opts out of that generation entirely). Writing it yourself gives you control over the reading order that screen readers and text-only clients will follow.
What should I watch out for when using Tailwind in email?
Always include pixelBasedPreset. Tailwind uses rem as its main unit, some email clients do not support it, and the preset converts styles to a 16px basis instead. The Tailwind component currently runs tailwindcss 4.1.12. Also note that since React Email 5.0, className only inlines styles on elements, not on components.

References

友田

友田 陽大

Developer of a METI Minister's Award–winning product. With TypeScript + Python + AWS, I deliver SaaS, industry DX, and production-grade generative AI (RAG) end to end — from requirements to infrastructure and operations — single-handedly.

I can take on the implementation from this article as an engagement

Transactional email infrastructure on Resend — design, implementation, and production operations

"We sent it" is not "it arrived." I close that gap in code and DNS rather than in operational vigilance: sender-domain authentication (SPF/DKIM/DMARC), a typed send path validated with Zod, idempotency keys and backoff for resilience, bounce and complaint handling over webhooks, RFC 8058 one-click unsubscribe, and structured logs that never carry PII. This site's own contact form, gated resources, email course and post-purchase mail all run on Resend in production — including the outage where misplaced authentication records took sending down entirely.

Available for both project-based (contract) and advisory engagements. Start with a free 30-minute consult.

最短ルート:カレンダーから直接予約

相談内容が固まっている方は、フォーム送信よりその場で日程を確定する方がスムーズです。下記から空き時間をお選びください。

  • 30分のオンライン無料相談
  • Google Meet / Zoom / Microsoft Teams
  • NDA 商談前締結可・無理な営業はいたしません
無料相談の空き枠を予約する

Also worth reading